diff --git a/.env.example b/.env.example index bac0af33c9..aa157cb7fa 100644 --- a/.env.example +++ b/.env.example @@ -14,15 +14,15 @@ REDIS_HOST=localhost REDIS_PORT=6379 # Authentication -# Generated by start.sh if empty -FIRST_SUPERUSER= -# Generated by start.sh if empty -FIRST_SUPERUSER_PASSWORD= +# Local demo bootstrap only; omit these in api_key mode. +# FIRST_SUPERUSER= +# FIRST_SUPERUSER_PASSWORD= ENCRYPTION_KEY= -AUTH_ENABLED=false +AUTH_MODE=api_key +# Opt into AUTH_MODE=local only for isolated local development. STATE_SECRET= -# Auth0 Configuration (required if AUTH_ENABLED=true) +# Auth0 Configuration (required if AUTH_MODE=auth0) AUTH0_DOMAIN= AUTH0_AUDIENCE= AUTH0_RULE_NAMESPACE= @@ -123,8 +123,6 @@ ADDITIONAL_CORS_ORIGINS= # Other Settings PROJECT_NAME=Airweave LOG_LEVEL=INFO -RUN_ALEMBIC_MIGRATIONS=true -RUN_DB_SYNC=true CODE_SUMMARIZER_ENABLED=false SVIX_URL=http://localhost:8071 diff --git a/.github/workflows/monke.yml b/.github/workflows/monke.yml index cc08125385..d4cb0470c0 100644 --- a/.github/workflows/monke.yml +++ b/.github/workflows/monke.yml @@ -1,13 +1,9 @@ name: Monke – parallel runners on: + # The owned fork uses credential-free source-store checks on pull requests. + # Upstream's live connector mutators require separate disposable accounts. workflow_dispatch: - pull_request: - paths: - # Only trigger on platform-related changes (reduced footprint) - - "monke/**" - - "backend/airweave/platform/**" - - ".github/workflows/monke.yml" concurrency: group: monke-${{ github.ref }} diff --git a/.github/workflows/owned-source-store.yml b/.github/workflows/owned-source-store.yml new file mode 100644 index 0000000000..e363ea8c76 --- /dev/null +++ b/.github/workflows/owned-source-store.yml @@ -0,0 +1,126 @@ +name: Owned source store + +on: + pull_request: + branches: [dev, main] + paths: + - backend/** + - evaluation/** + - vespa/app/** + - Makefile + - .github/workflows/owned-source-store.yml + push: + branches: [dev] + paths: + - backend/** + - evaluation/** + - vespa/app/** + - Makefile + - .github/workflows/owned-source-store.yml + +permissions: + contents: read + +concurrency: + group: owned-store-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + evaluation: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.13' + - name: Verify offline retrieval metrics and file boundaries + run: | + python -m venv .venv-evaluation + .venv-evaluation/bin/python -m pip install ir-measures==0.4.3 pydantic==2.11.9 pytest==8.3.5 + .venv-evaluation/bin/python -m pytest evaluation/tests -q --confcutdir=evaluation + + correctness: + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + vespa: + image: vespaengine/vespa@sha256:5c30f5c41e7563498c4f925db6a837a3848f04726a3ed26aed4a7c8ab69f18fd + ports: + - 8081:8081 + - 19071:19071 + options: >- + --hostname vespa + --health-cmd "curl -f http://localhost:19071/state/v1/health" + --health-interval 10s --health-timeout 5s --health-retries 30 + minilm: + image: semitechnologies/transformers-inference@sha256:c9c13f047ee31488e686c4a7b2ea7ccfcdec16ee825447450dc047cf97791590 + env: + ENABLE_CUDA: '0' + ports: + - 8080:8080 + options: --memory=2g --cpus=2 + postgres: + image: postgres:16.15 + env: + POSTGRES_USER: sync_test + POSTGRES_PASSWORD: disposable-ci-only + POSTGRES_DB: sync_tests + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U sync_test -d sync_tests" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + CANONICAL_TEST_DATABASE_URL: postgresql+asyncpg://sync_test:disposable-ci-only@localhost:5432/sync_tests + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.13' + - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a + with: + version: 2.3.2 + virtualenvs-create: true + virtualenvs-in-project: true + - name: Install locked dependencies + working-directory: backend + run: poetry install --with dev,lint --no-root --no-interaction + - name: Verify durable records and publication + run: make test-store + - name: Verify source capture and transport + run: make test-capture + - name: Verify connection provisioning and recovery + run: make test-provisioning + - name: Verify indexed retrieval and visibility + run: make test-search + - name: Verify service authentication + run: make test-auth + - name: Verify dependency readiness + run: make test-health + - name: Verify OCR execution boundaries and fallback + run: make test-ocr + - name: Verify projection recovery and maintenance workflows + run: make test-worker + - name: Record tested Vespa image + run: docker image inspect vespaengine/vespa@sha256:5c30f5c41e7563498c4f925db6a837a3848f04726a3ed26aed4a7c8ab69f18fd --format '{{json .RepoDigests}}' + - name: Verify real Vespa schema and retrieval + env: + OWNED_VESPA_TEST: '1' + OWNED_MINILM_TEST: '1' + EMBEDDING_DIMENSIONS: '384' + run: make test-index + + image: + needs: correctness + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Build the deployable image without publishing + run: make build IMAGE=almanac-source-store:ci + - name: Verify packaged Python and entrypoint syntax + run: | + docker run --rm --entrypoint python almanac-source-store:ci -m compileall -q airweave + docker run --rm --entrypoint /bin/sh almanac-source-store:ci -n /app/entrypoint.sh + docker run --rm --entrypoint python almanac-source-store:ci -m scripts.smoke_local_ocr --tessdata /usr/share/tessdata diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..edf47e04fc --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +.DEFAULT_GOAL := help +BACKEND := backend +PYTHON := .venv/bin/python +PYTEST := .venv/bin/pytest +IMAGE ?= almanac-source-store:local + +.PHONY: help setup check test-store test-provisioning test-capture test-search test-auth test-health test-worker test-ocr test-index validate-deploy build + +help: + @echo 'setup Install the locked backend and development dependencies' + @echo 'build Build the backend image locally; set IMAGE to choose its tag' + @echo 'test-store Verify record transactions and publication on disposable PostgreSQL' + @echo 'test-capture Verify source capture, transport and blob handling' + @echo 'test-search Verify search services and visibility behavior' + @echo 'test-auth Verify service-key and source authorization boundaries' + @echo 'test-health Verify dependency probes and readiness behavior' + @echo 'test-worker Verify owned projection, recovery and maintenance workflows' + @echo 'test-ocr Verify local OCR boundaries and per-file fallback' + @echo 'test-index Deploy/test schemas on explicitly disposable local Vespa' + @echo 'check Run store, capture, search, authentication and health checks' + @echo 'validate-deploy Validate the Porter application manifest locally (no deployment)' + @echo '' + @echo 'test-store requires CANONICAL_TEST_DATABASE_URL; it creates and removes test schemas.' + @echo 'See deploy/README.md for staging prerequisites and explicit deployment steps.' + +build: + docker build --tag "$(IMAGE)" $(BACKEND) + +validate-deploy: + porter apply validate -f porter.yaml + +setup: + cd $(BACKEND) && POETRY_VIRTUALENVS_IN_PROJECT=true uvx --from poetry==2.3.2 poetry install --with dev,lint --no-root --no-interaction + +test-store: + @test -n "$$CANONICAL_TEST_DATABASE_URL" || (echo 'Set CANONICAL_TEST_DATABASE_URL to a disposable PostgreSQL database.' >&2; exit 1) + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/domains/entities/canonical/tests airweave/domains/native_ingestion/tests airweave/domains/syncs/tests + +test-capture: + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/platform/sources/tests tests/unit/platform/sources/records tests/unit/platform/sources/test_*_capture.py tests/unit/domains/entities tests/unit/platform/http_client/test_composio_transport.py tests/unit/platform/http_client/test_logging_privacy.py airweave/domains/storage/tests/test_file_service.py + +test-provisioning: + @test -n "$$CANONICAL_TEST_DATABASE_URL" || (echo 'Set CANONICAL_TEST_DATABASE_URL to a disposable PostgreSQL database.' >&2; exit 1) + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/domains/owned_provisioning/tests airweave/domains/source_connections/tests airweave/domains/temporal/tests airweave/domains/sources/tests/test_lifecycle.py airweave/domains/sync_pipeline/tests/test_factory.py + +test-search: + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/domains/search airweave/domains/embedders + PYTHONPATH=$(BACKEND):. $(BACKEND)/$(PYTEST) -q -o log_cli=false $(BACKEND)/tests/unit/search/test_owned_evaluation.py $(BACKEND)/tests/unit/search/test_owned_replay.py + +test-auth: + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/api/tests/test_service_auth.py airweave/api/tests/test_sync_authorization.py airweave/api/tests/test_context_resolver_auth.py + +test-health: + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/core/health/tests airweave/adapters/health/tests airweave/core/container/tests/test_health_wiring.py + +test-worker: + @test -n "$$CANONICAL_TEST_DATABASE_URL" || (echo 'Set CANONICAL_TEST_DATABASE_URL to a disposable PostgreSQL database.' >&2; exit 1) + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/domains/temporal/activities/tests/test_project_canonical_records.py airweave/domains/temporal/activities/tests/test_discover_native_projection.py airweave/domains/temporal/activities/tests/test_cleanup_stuck_sync_jobs.py airweave/domains/temporal/workflows/tests/test_native_projection_recovery.py airweave/domains/temporal/workflows/tests/test_source_projection_replay.py airweave/domains/temporal/workflows/tests/test_reproject_command.py airweave/domains/temporal/workflows/tests/test_cleanup_workflows.py airweave/domains/temporal/workflows/tests/test_canonical_projection.py airweave/domains/temporal/worker/tests/test_wiring.py airweave/domains/temporal/worker/tests/test_config.py airweave/domains/temporal/worker/tests/test_worker.py airweave/domains/temporal/worker/tests/test_worker_ocr_guard.py + +test-ocr: + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false airweave/domains/ocr/tests airweave/core/container/tests/test_ocr_wiring.py + +test-index: + @test "$$OWNED_VESPA_TEST" = 1 || (echo 'Set OWNED_VESPA_TEST=1 only for a disposable Vespa at localhost:8081/19071; this deploys schemas.' >&2; exit 1) + cd $(BACKEND) && $(PYTEST) -q -o log_cli=false tests/integration/test_owned_vespa.py tests/integration/test_canonical_search_prefilters.py airweave/domains/entities/canonical/tests/test_real_vespa_projection.py + +check: test-store test-provisioning test-capture test-search test-auth test-health test-worker test-ocr diff --git a/backend/Dockerfile b/backend/Dockerfile index b4b93f363d..3ea8070cdb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,6 +28,13 @@ COPY pyproject.toml poetry.lock ./ RUN --mount=type=cache,target=/opt/poetry-cache \ poetry install --only=main --no-interaction --no-ansi --no-root +# Optional local OCR models: pinned upstream artifacts, verified by BuildKit. +# Inference never downloads weights. Enable explicitly with LOCAL_OCR_TESSDATA_PATH. +ADD --chmod=644 --checksum=sha256:7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2 https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/87416418657359cb625c412a48b6e1d6d41c29bd/eng.traineddata /usr/share/tessdata/eng.traineddata +ADD --chmod=644 --checksum=sha256:4c73ffc59d497c186b19d1e90f5d721d678ea6b2e277b719bee4e2af12271825 https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/87416418657359cb625c412a48b6e1d6d41c29bd/hin.traineddata /usr/share/tessdata/hin.traineddata + +ADD --chmod=644 --checksum=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/87416418657359cb625c412a48b6e1d6d41c29bd/LICENSE /usr/share/tessdata/LICENSE + # Copy entrypoint script with proper permissions COPY --chmod=755 entrypoint.sh /app/entrypoint.sh diff --git a/backend/SERVICE_AUTH.md b/backend/SERVICE_AUTH.md new file mode 100644 index 0000000000..72b3eb9fc8 --- /dev/null +++ b/backend/SERVICE_AUTH.md @@ -0,0 +1,55 @@ +# Standalone service authentication and database setup + +Use `AUTH_MODE=api_key` for the owned service. Every ordinary API request needs +an existing organization-scoped `X-API-Key`. The default is `api_key`: omitting +credentials does not select a demo user. This mode makes no Auth0 network calls +and does not create users from service credentials. User-only routes still +require user authentication; existing Connect sessions retain their own token +and scope checks. Auth0 mode preserves the existing user-first authentication +precedence when both user and API-key credentials are supplied. + +`AUTH_MODE=auth0` retains hosted Auth0 user authentication and API-key access. +The existing Auth0 configuration remains required in that mode. +`AUTH_MODE=local` explicitly enables insecure demo authentication and is allowed +only for `ENVIRONMENT=local` or `test`. Never deploy local mode. The deprecated +`AUTH_ENABLED=true/false` is accepted as a migration input mapping to auth0/local; +contradicting it with AUTH_MODE is an error. Remove AUTH_ENABLED when adopting +AUTH_MODE. The environment label is operator-controlled, not an isolation boundary. + +Keys are checked against persisted credentials on every request; expiry and +revocation take effect without waiting for a key-to-organization cache. All +record and job authorization still uses the key's organization. Authenticated +sync SSE subscriptions authorize job ownership before subscribing. + +## Explicit database setup + +Neither API startup nor the container entrypoint migrates the database or seeds +users, organizations, or keys. `RUN_ALEMBIC_MIGRATIONS` is no longer a runtime +switch. Run setup as a separately authorized deployment job with reviewed, +isolated database credentials: + +```sh +# From backend/, against the intended isolated target only: +poetry run alembic upgrade head +poetry run python -m airweave.db.bootstrap +``` + +The second command installs native destination definitions and the initial embedding +metadata. Ordinary startup validates that metadata without inserting it. Service +mode does not require FIRST_SUPERUSER or FIRST_SUPERUSER_PASSWORD; these are +local bootstrap inputs only. Provision the +intended service organization and key through the existing model/CRUD operator +workflow, store the key in secret management, then start the API and workers. +Do not print keys in logs. Runtime replicas should not need migration privileges. +Temporal database setup is separate and must also target isolated resources. + +For a disposable local demo only: + +```sh +AUTH_MODE=local poetry run python -m airweave.db.bootstrap --local-superuser +``` + +This opts into the legacy demo user/org/key provisioning. It is refused in +api_key or auth0 mode. The production entrypoint starts Uvicorn without reload. +Almanac/WorkOS remains end-user identity authority; this service does not +introduce an independent customer login system. diff --git a/backend/airweave/adapters/health/temporal.py b/backend/airweave/adapters/health/temporal.py index 965f6a299d..62e0b01307 100644 --- a/backend/airweave/adapters/health/temporal.py +++ b/backend/airweave/adapters/health/temporal.py @@ -15,17 +15,23 @@ class TemporalHealthProbe(HealthProbe): """Probes Temporal via the gRPC health check on its service client.""" def __init__(self, get_client: Callable[[], TemporalClientType | None]) -> None: + """Use the application's current connection without creating a probe-owned client.""" self._get_client = get_client @property def name(self) -> str: + """Identify this dependency in readiness configuration and responses.""" return "temporal" async def check(self) -> DependencyCheck: + """Report the actual service health, or skipped until a client is available.""" client = self._get_client() if client is None: return DependencyCheck(status=CheckStatus.skipped) start = time.perf_counter() - await client.service_client.check_health() + serving = await client.service_client.check_health() latency = (time.perf_counter() - start) * 1000 - return DependencyCheck(status=CheckStatus.up, latency_ms=round(latency, 2)) + return DependencyCheck( + status=CheckStatus.up if serving else CheckStatus.down, + latency_ms=round(latency, 2), + ) diff --git a/backend/airweave/adapters/health/tests/test_health_probes.py b/backend/airweave/adapters/health/tests/test_health_probes.py index e22550b508..9cacf0f31c 100644 --- a/backend/airweave/adapters/health/tests/test_health_probes.py +++ b/backend/airweave/adapters/health/tests/test_health_probes.py @@ -6,7 +6,6 @@ from airweave.schemas.health import CheckStatus - # --------------------------------------------------------------------------- # PostgresHealthProbe # --------------------------------------------------------------------------- @@ -104,7 +103,7 @@ async def test_client_available_returns_up(self): from airweave.adapters.health.temporal import TemporalHealthProbe client = MagicMock() - client.service_client.check_health = AsyncMock() + client.service_client.check_health = AsyncMock(return_value=True) probe = TemporalHealthProbe(lambda: client) result = await probe.check() @@ -114,6 +113,16 @@ async def test_client_available_returns_up(self): assert result.latency_ms >= 0 client.service_client.check_health.assert_awaited_once() + @pytest.mark.asyncio + async def test_server_not_serving_returns_down(self): + from airweave.adapters.health.temporal import TemporalHealthProbe + + client = MagicMock() + client.service_client.check_health = AsyncMock(return_value=False) + result = await TemporalHealthProbe(lambda: client).check() + + assert result.status == CheckStatus.down + @pytest.mark.asyncio async def test_client_available_but_unhealthy(self): from airweave.adapters.health.temporal import TemporalHealthProbe diff --git a/backend/airweave/adapters/identity/auth0.py b/backend/airweave/adapters/identity/auth0.py index f271f44614..3fa63d8064 100644 --- a/backend/airweave/adapters/identity/auth0.py +++ b/backend/airweave/adapters/identity/auth0.py @@ -17,7 +17,7 @@ wait_exponential, ) -from airweave.core.config import settings +from airweave.core.config import AuthMode, settings from airweave.core.logging import logger from airweave.core.protocols.identity import ( IdentityProvider, @@ -416,7 +416,7 @@ async def add_enabled_connection_to_organization( # --------------------------------------------------------------------------- auth0_management_client: Optional[Auth0ManagementClient] = None -if settings.AUTH_ENABLED: +if settings.AUTH_MODE == AuthMode.AUTH0: auth0_management_client = Auth0ManagementClient() diff --git a/backend/airweave/adapters/storage/aws_s3.py b/backend/airweave/adapters/storage/aws_s3.py index ef61ac7915..8b4a45cb79 100644 --- a/backend/airweave/adapters/storage/aws_s3.py +++ b/backend/airweave/adapters/storage/aws_s3.py @@ -12,6 +12,7 @@ from airweave.domains.storage.exceptions import ( StorageException, StorageNotFoundError, + StorageReadLimitExceeded, ) from airweave.domains.storage.protocols import StorageBackend @@ -127,15 +128,30 @@ async def write_file(self, path: str, content: bytes) -> None: except Exception as e: raise StorageException(f"Failed to write file to {path}: {e}") - async def read_file(self, path: str) -> bytes: + async def read_file(self, path: str, *, max_bytes: int | None = None) -> bytes: """Read binary content from S3.""" + from aiohttp import ClientError as HttpClientError + from botocore.exceptions import BotoCoreError, ClientError + + if max_bytes is not None and max_bytes < 0: + raise ValueError("max_bytes must be nonnegative") key = self._resolve(path) try: client = await self._get_client() response = await client.get_object(Bucket=self.bucket, Key=key) async with response["Body"] as stream: - return await stream.read() - except Exception as e: + if max_bytes is None: + return await stream.read() + content = bytearray() + while len(content) <= max_bytes: + chunk = await stream.read(min(65536, max_bytes + 1 - len(content))) + if not chunk: + return bytes(content) + content.extend(chunk) + raise StorageReadLimitExceeded("Stored object exceeds read limit") + except StorageReadLimitExceeded: + raise + except (OSError, HttpClientError, BotoCoreError, ClientError) as e: if "NoSuchKey" in str(e) or "404" in str(e): raise StorageNotFoundError(f"Path not found: {path}") raise StorageException(f"Failed to read file from {path}: {e}") @@ -150,6 +166,20 @@ async def exists(self, path: str) -> bool: except Exception: return False + async def delete_file(self, path: str) -> None: + """S3 DeleteObject is idempotent and never interprets the key as a prefix.""" + from botocore.exceptions import ClientError + + try: + client = await self._get_client() + await client.delete_object(Bucket=self.bucket, Key=self._resolve(path)) + except ClientError as exc: + if exc.response.get("Error", {}).get("Code") == "NoSuchKey": + return + raise StorageException("Exact S3 object deletion failed") from None + except Exception: + raise StorageException("Exact S3 object deletion failed") from None + async def delete(self, path: str) -> bool: """Delete object or all objects under prefix.""" key = self._resolve(path) diff --git a/backend/airweave/adapters/storage/azure_blob.py b/backend/airweave/adapters/storage/azure_blob.py index c47b625d62..fc18a425a3 100644 --- a/backend/airweave/adapters/storage/azure_blob.py +++ b/backend/airweave/adapters/storage/azure_blob.py @@ -12,6 +12,7 @@ from airweave.domains.storage.exceptions import ( StorageException, StorageNotFoundError, + StorageReadLimitExceeded, ) from airweave.domains.storage.protocols import StorageBackend @@ -133,19 +134,30 @@ async def write_file(self, path: str, content: bytes) -> None: except Exception as e: raise StorageException(f"Failed to write file to {path}: {e}") - async def read_file(self, path: str) -> bytes: + async def read_file(self, path: str, *, max_bytes: int | None = None) -> bytes: """Read binary content from Azure Blob.""" + from azure.core.exceptions import AzureError + + if max_bytes is not None and max_bytes < 0: + raise ValueError("max_bytes must be nonnegative") blob_path = self._resolve(path) try: container_client = await self._get_container_client() blob_client = container_client.get_blob_client(blob_path) if not await blob_client.exists(): raise StorageNotFoundError(f"Path not found: {path}") - download_stream = await blob_client.download_blob() - return await download_stream.readall() + download_stream = await blob_client.download_blob( + **({"offset": 0, "length": max_bytes + 1} if max_bytes is not None else {}) + ) + content = await download_stream.readall() + if max_bytes is not None and len(content) > max_bytes: + raise StorageReadLimitExceeded("Stored object exceeds read limit") + return content except StorageNotFoundError: raise - except Exception as e: + except StorageReadLimitExceeded: + raise + except (OSError, AzureError) as e: raise StorageException(f"Failed to read file from {path}: {e}") async def exists(self, path: str) -> bool: @@ -158,6 +170,18 @@ async def exists(self, path: str) -> bool: except Exception: return False + async def delete_file(self, path: str) -> None: + """Delete one exact blob; snapshots/leases errors remain explicit failures.""" + from azure.core.exceptions import ResourceNotFoundError + + try: + container = await self._get_container_client() + await container.get_blob_client(self._resolve(path)).delete_blob() + except ResourceNotFoundError: + return + except Exception: + raise StorageException("Exact Azure blob deletion failed") from None + async def delete(self, path: str) -> bool: """Delete blob or all blobs under prefix. diff --git a/backend/airweave/adapters/storage/filesystem.py b/backend/airweave/adapters/storage/filesystem.py index f4b892ef94..ce383ccc21 100644 --- a/backend/airweave/adapters/storage/filesystem.py +++ b/backend/airweave/adapters/storage/filesystem.py @@ -19,6 +19,7 @@ from airweave.domains.storage.exceptions import ( StorageException, StorageNotFoundError, + StorageReadLimitExceeded, ) from airweave.domains.storage.protocols import StorageBackend @@ -95,8 +96,10 @@ async def write_file(self, path: str, content: bytes) -> None: except Exception as e: raise StorageException(f"Failed to write file to {path}: {e}") - async def read_file(self, path: str) -> bytes: + async def read_file(self, path: str, *, max_bytes: int | None = None) -> bytes: """Read binary content from filesystem.""" + if max_bytes is not None and max_bytes < 0: + raise ValueError("max_bytes must be nonnegative") full_path = self._resolve(path) if not full_path.exists(): @@ -104,8 +107,13 @@ async def read_file(self, path: str) -> bytes: try: async with aiofiles.open(full_path, "rb") as f: - return await f.read() - except Exception as e: + content = await f.read() if max_bytes is None else await f.read(max_bytes + 1) + if max_bytes is not None and len(content) > max_bytes: + raise StorageReadLimitExceeded("Stored object exceeds read limit") + return content + except StorageReadLimitExceeded: + raise + except OSError as e: raise StorageException(f"Failed to read file from {path}: {e}") async def exists(self, path: str) -> bool: @@ -113,6 +121,15 @@ async def exists(self, path: str) -> bool: full_path = self._resolve(path) return await aiofiles.os.path.exists(full_path) + async def delete_file(self, path: str) -> None: + """Unlink one exact file; never recursively remove a directory.""" + try: + await aiofiles.os.remove(self._resolve(path)) + except FileNotFoundError: + return + except OSError: + raise StorageException("Exact file deletion failed") from None + async def delete(self, path: str) -> bool: """Delete file or directory from filesystem.""" full_path = self._resolve(path) diff --git a/backend/airweave/adapters/storage/gcp_gcs.py b/backend/airweave/adapters/storage/gcp_gcs.py index 900d5755a8..4bdbaf48dd 100644 --- a/backend/airweave/adapters/storage/gcp_gcs.py +++ b/backend/airweave/adapters/storage/gcp_gcs.py @@ -12,6 +12,7 @@ from airweave.domains.storage.exceptions import ( StorageException, StorageNotFoundError, + StorageReadLimitExceeded, ) from airweave.domains.storage.protocols import StorageBackend @@ -142,8 +143,13 @@ def _write(): except Exception as e: raise StorageException(f"Failed to write file to {path}: {e}") - async def read_file(self, path: str) -> bytes: + async def read_file(self, path: str, *, max_bytes: int | None = None) -> bytes: """Read binary content from GCS.""" + from google.api_core.exceptions import GoogleAPIError + from requests.exceptions import RequestException + + if max_bytes is not None and max_bytes < 0: + raise ValueError("max_bytes must be nonnegative") import asyncio blob_name = self._resolve(path) @@ -154,7 +160,16 @@ def _read(): bucket = self._get_bucket() blob = bucket.blob(blob_name) try: - return blob.download_as_bytes() + content = blob.download_as_bytes( + **( + {"start": 0, "end": max_bytes, "raw_download": True} + if max_bytes is not None + else {} + ) + ) + if max_bytes is not None and len(content) > max_bytes: + raise StorageReadLimitExceeded("Stored object exceeds read limit") + return content except NotFound: raise StorageNotFoundError(f"Path not found: {path}") @@ -162,7 +177,9 @@ def _read(): return await asyncio.to_thread(_read) except StorageNotFoundError: raise - except Exception as e: + except StorageReadLimitExceeded: + raise + except (OSError, GoogleAPIError, RequestException) as e: raise StorageException(f"Failed to read file from {path}: {e}") async def exists(self, path: str) -> bool: @@ -181,6 +198,22 @@ def _exists(): except Exception: return False + async def delete_file(self, path: str) -> None: + """Delete one exact object off-loop; never enumerate a prefix.""" + import asyncio + + from google.cloud.exceptions import NotFound + + def remove(): + self._get_bucket().blob(self._resolve(path)).delete() + + try: + await asyncio.to_thread(remove) + except NotFound: + return + except Exception: + raise StorageException("Exact GCS object deletion failed") from None + async def delete(self, path: str) -> bool: """Delete blob or all blobs under prefix.""" import asyncio diff --git a/backend/airweave/api/auth.py b/backend/airweave/api/auth.py index d98b35eaed..b4b3ab9518 100644 --- a/backend/airweave/api/auth.py +++ b/backend/airweave/api/auth.py @@ -5,7 +5,7 @@ from fastapi_auth0 import Auth0, Auth0User from jose import jwt -from airweave.core.config import settings +from airweave.core.config import AuthMode, settings # Add a method to auth0 instance to verify tokens directly @@ -19,9 +19,12 @@ async def get_user_from_token(token: str): Auth0User if token is valid, None otherwise """ # If auth is disabled, just return a mock user - if not settings.AUTH_ENABLED: + if settings.AUTH_MODE == AuthMode.LOCAL: return Auth0User(sub="mock-user-id", email=settings.FIRST_SUPERUSER) + if settings.AUTH_MODE != AuthMode.AUTH0: + return None + try: if not token: return None @@ -63,7 +66,7 @@ async def get_user_from_token(token: str): # Initialize Auth0 only if authentication is enabled -if settings.AUTH_ENABLED: +if settings.AUTH_MODE == AuthMode.AUTH0: auth0 = Auth0( domain=settings.AUTH0_DOMAIN, api_audience=settings.AUTH0_AUDIENCE, @@ -71,11 +74,11 @@ async def get_user_from_token(token: str): ) else: # Create a mock Auth0 instance that doesn't make network calls - class MockAuth0: - """A mock Auth0 class that doesn't make network calls for testing/development.""" + class InactiveAuth0: + """Dependency for modes without Auth0; only explicit local mode returns a demo user.""" def __init__(self): - """Initialize the mock Auth0 instance.""" + """Initialize a network-free Auth0 dependency.""" self.domain = "mock-domain.auth0.com" self.audience = "https://mock-api/" self.algorithms = ["RS256"] @@ -83,9 +86,11 @@ def __init__(self): self.auth0_user_model = Auth0User async def get_user(self): - """Always return a mock user in development mode.""" + """Return a demo user only when insecure local mode is explicit.""" # For development and testing - return Auth0User(sub="mock-user-id", email=settings.FIRST_SUPERUSER) + if settings.AUTH_MODE == AuthMode.LOCAL: + return Auth0User(sub="mock-user-id", email=settings.FIRST_SUPERUSER) + return None - auth0 = MockAuth0() - logging.info("Using mock Auth0 instance because AUTH_ENABLED=False") + auth0 = InactiveAuth0() + logging.info("Auth0 disabled for authentication mode %s", settings.AUTH_MODE.value) diff --git a/backend/airweave/api/backend_actor.py b/backend/airweave/api/backend_actor.py new file mode 100644 index 0000000000..aa17b5fe7b --- /dev/null +++ b/backend/airweave/api/backend_actor.py @@ -0,0 +1,13 @@ +"""Shared backend-only authority gate; organization keys retain their existing scope.""" + +from fastapi import Depends, HTTPException + +from airweave.api import deps +from airweave.api.context import ApiContext + + +def backend_actor(ctx: ApiContext = Depends(deps.get_context)) -> ApiContext: + """A user session cannot act as Almanac's credential/identity authority.""" + if not ctx.is_api_key_auth: + raise HTTPException(status_code=403, detail="Owned provisioning requires a backend API key") + return ctx diff --git a/backend/airweave/api/context_resolver.py b/backend/airweave/api/context_resolver.py index d1d5128700..edf954eba2 100644 --- a/backend/airweave/api/context_resolver.py +++ b/backend/airweave/api/context_resolver.py @@ -18,8 +18,12 @@ from airweave import schemas from airweave.analytics.service import analytics from airweave.api.context import ApiContext, RequestHeaders -from airweave.core.config import settings -from airweave.core.exceptions import NotFoundException, RateLimitExceededException +from airweave.core.config import AuthMode, settings +from airweave.core.exceptions import ( + NotFoundException, + PermissionException, + RateLimitExceededException, +) from airweave.core.logging import logger from airweave.core.protocols.cache import ContextCache from airweave.core.protocols.rate_limiter import RateLimiter @@ -105,14 +109,14 @@ async def authenticate_user_only( Used by ``deps.get_user``. """ - if not settings.AUTH_ENABLED: + if settings.AUTH_MODE == AuthMode.LOCAL: user = await self._fetch_system_user(db) if user: return user raise HTTPException(status_code=401, detail="User not found") - if not auth0_user: - raise HTTPException(status_code=401, detail="User email not found in Auth0") + if settings.AUTH_MODE != AuthMode.AUTH0 or not auth0_user: + raise HTTPException(status_code=401, detail="User authentication is required") user = await self._fetch_auth0_user(db, auth0_user) if not user: @@ -130,9 +134,9 @@ async def _authenticate( x_api_key: Optional[str], request: Request, ) -> AuthResult: - if not settings.AUTH_ENABLED: + if settings.AUTH_MODE == AuthMode.LOCAL: return await self._authenticate_system(db) - if auth0_user: + if settings.AUTH_MODE == AuthMode.AUTH0 and auth0_user: return await self._authenticate_auth0(db, auth0_user) if x_api_key: return await self._authenticate_api_key(db, x_api_key, request) @@ -165,14 +169,6 @@ async def _authenticate_api_key( self, db: AsyncSession, api_key: str, request: Request ) -> AuthResult: try: - org_id = await self._cache.get_api_key_org_id(api_key) - if org_id: - return AuthResult( - method=AuthMethod.API_KEY, - metadata={"api_key_id": "cached", "created_by": None}, - api_key_org_id=str(org_id), - ) - api_key_obj = await self._api_keys.get_by_key(db, key=api_key) org_id = api_key_obj.organization_id @@ -183,8 +179,6 @@ async def _authenticate_api_key( f"endpoint={request.url.path} created_by={api_key_obj.created_by_email}" ) - await self._cache.set_api_key_org_id(api_key, org_id) - return AuthResult( method=AuthMethod.API_KEY, metadata={ @@ -194,7 +188,7 @@ async def _authenticate_api_key( api_key_org_id=str(org_id), ) - except (ValueError, NotFoundException) as e: + except (ValueError, NotFoundException, PermissionException) as e: logger.error(f"API key validation failed: {e}") if "expired" in str(e): raise HTTPException(status_code=403, detail="API key has expired") from e @@ -291,8 +285,7 @@ async def _validate_organization_access( ) elif auth.method == AuthMethod.API_KEY and x_api_key: - api_key_obj = await self._api_keys.get_by_key(db, key=x_api_key) - if str(api_key_obj.organization_id) != organization_id: + if auth.api_key_org_id != organization_id: raise HTTPException( status_code=403, detail=f"API key does not have access to organization {organization_id}", diff --git a/backend/airweave/api/deps.py b/backend/airweave/api/deps.py index 1e0b305910..de395e8759 100644 --- a/backend/airweave/api/deps.py +++ b/backend/airweave/api/deps.py @@ -9,7 +9,7 @@ from fastapi import Depends, Header, HTTPException, Request from fastapi_auth0 import Auth0User -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from airweave import crud, schemas from airweave.api.auth import auth0 @@ -17,13 +17,17 @@ from airweave.api.context_resolver import ContextResolver from airweave.api.inject import Inject # noqa: F401 — re-exported for backward compat from airweave.core import container as container_mod -from airweave.core.config import settings +from airweave.core.config import AuthMode, settings from airweave.core.container import Container from airweave.core.logging import ContextualLogger from airweave.core.protocols.cache import ContextCache from airweave.core.protocols.rate_limiter import RateLimiter from airweave.core.shared_models import AuthMethod +from airweave.db import session as db_session from airweave.db.session import get_db +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore from airweave.domains.organizations.repository import ApiKeyRepository, OrganizationRepository from airweave.domains.users.repository import UserRepository @@ -106,6 +110,38 @@ async def get_context( return await resolver.resolve(request, db, auth0_user, x_api_key, x_organization_id) +def get_search_session_factory() -> async_sessionmaker[AsyncSession]: + """Resolve the configured factory dynamically, including isolated local deployments.""" + return db_session.AsyncSessionLocal + + +async def get_owned_search_context( + request: Request, + sessions: async_sessionmaker[AsyncSession] = Depends(get_search_session_factory), + x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + x_organization_id: Optional[str] = Header(None, alias="X-Organization-ID"), + auth0_user: Optional[Auth0User] = Depends(auth0.get_user), + cache: ContextCache = Inject(ContextCache), + rate_limiter: RateLimiter = Inject(RateLimiter), +) -> ApiContext: + """Finish this read route's authentication transaction before search network waits. + + Reuse ordinary context resolution. Closing our own session preserves the previous + read route's rollback semantics, including Auth0's uncommitted last-active update. + Other routes retain their existing request-owned session. + """ + async with sessions() as db: + return await get_context( + request=request, + db=db, + x_api_key=x_api_key, + x_organization_id=x_organization_id, + auth0_user=auth0_user, + cache=cache, + rate_limiter=rate_limiter, + ) + + async def get_logger( context: ApiContext = Depends(get_context), ) -> ContextualLogger: @@ -139,7 +175,7 @@ async def get_user_from_token(token: str, db: AsyncSession) -> Optional[schemas. if token.startswith("Bearer "): token = token[7:] - if not settings.AUTH_ENABLED: + if settings.AUTH_MODE == AuthMode.LOCAL: user = await crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) if user: return schemas.User.model_validate(user) @@ -230,3 +266,10 @@ async def get_connect_session( ) except (KeyError, ValueError) as e: raise HTTPException(status_code=401, detail="Invalid session token payload") from e + + +def get_canonical_query_service() -> CanonicalQueryService: + """Wire stateless exact-record reads with the deployment's cursor signing key.""" + return CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), settings.STATE_SECRET + ) diff --git a/backend/airweave/api/examples.py b/backend/airweave/api/examples.py index 632e9c99e2..180be34e0b 100644 --- a/backend/airweave/api/examples.py +++ b/backend/airweave/api/examples.py @@ -183,9 +183,12 @@ "short_name": "github", "collection": "engineering-docs-ab123", "config_fields": { - "repo_name": "company/main-repo", - "include_issues": True, - "include_pull_requests": True, + "expected_user_id": 42, + "repositories": [ + {"repository_id": 100, "owner_id": 200, "full_name": "company/main-repo"} + ], + "include_code": True, + "include_conversations": True, }, "auth_fields": { "personal_access_token": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", @@ -238,12 +241,6 @@ "type": "string", "secret": True, }, - { - "name": "repo_name", - "title": "Repository Name", - "description": "Full repository name in format 'owner/repo'", - "type": "string", - }, ] }, }, diff --git a/backend/airweave/api/middleware.py b/backend/airweave/api/middleware.py index 6237aefa0a..791349c00d 100644 --- a/backend/airweave/api/middleware.py +++ b/backend/airweave/api/middleware.py @@ -33,6 +33,19 @@ from airweave.core.protocols import HttpMetrics +def _native_request(request: Request) -> bool: + """Native publisher paths carry identities and retained source data.""" + path = request.url.path + return "/native/sources/" in path or path.endswith("/native/sources") + + +def _observable_url(request: Request) -> str: + """Keep route shape for native requests without publisher keys or query values.""" + if not _native_request(request): + return str(request.url) + return _build_endpoint_name(request, fallback="/native/sources/{native_path}") + + async def add_request_id(request: Request, call_next: callable) -> Response: """Middleware to generate and add a request ID to the request for tracing. @@ -68,7 +81,8 @@ async def log_requests(request: Request, call_next: callable) -> Response: duration = time.time() - start_time logger.info( ( - f"Handled request {request.method} {request.url} in {duration:.2f} seconds." + f"Handled request {request.method} {_observable_url(request)} " + f"in {duration:.2f} seconds." f"Response code: {response.status_code}" ) ) @@ -92,6 +106,9 @@ async def exception_logging_middleware(request: Request, call_next: callable) -> response = await call_next(request) return response except Exception as exc: + if _native_request(request): + logger.error(f"Native request failed: {type(exc).__name__}") + return JSONResponse(status_code=500, content={"detail": "Internal Server Error"}) # Always log the full exception details logger.error(f"Unhandled exception: {exc}\n{traceback.format_exc()}") @@ -144,7 +161,7 @@ async def request_timeout_middleware(request: Request, call_next: callable) -> R except asyncio.TimeoutError: logger.warning( f"Request timeout after {settings.API_REQUEST_TIMEOUT_SECONDS}s: " - f"{request.method} {request.url}" + f"{request.method} {_observable_url(request)}" ) return JSONResponse( status_code=504, @@ -180,7 +197,7 @@ async def request_body_size_middleware(request: Request, call_next: callable) -> actual_size_mb = content_length_bytes / (1024 * 1024) logger.warning( f"Request body too large: {actual_size_mb:.2f}MB exceeds limit " - f"of {max_size_mb:.2f}MB for {request.method} {request.url}" + f"of {max_size_mb:.2f}MB for {request.method} {_observable_url(request)}" ) return JSONResponse( status_code=413, @@ -288,12 +305,18 @@ async def dispatch(self, request: Request, call_next): ) response.headers["Access-Control-Allow-Headers"] = "*" response.headers["Access-Control-Allow-Credentials"] = "true" - logger.debug(f"Handled OPTIONS preflight for {path} from origin {origin}") + logger.debug( + f"Handled OPTIONS preflight for " + f"{_observable_url(request) if _native_request(request) else path} " + f"from origin {origin}" + ) return response else: # Not allowed, return 403 logger.debug( - f"Rejected OPTIONS preflight for {path} from disallowed origin {origin}" + f"Rejected OPTIONS preflight for " + f"{_observable_url(request) if _native_request(request) else path} " + f"from disallowed origin {origin}" ) return Response(status_code=403) @@ -358,6 +381,9 @@ async def validation_exception_handler( """ # Extract basic error messages error_messages = unpack_validation_error(exc) + if _native_request(request): + logger.error(f"Native request validation failed: {len(exc.errors())} errors") + return JSONResponse(status_code=422, content=error_messages) logger.error(f"Validation error: {error_messages}") if settings.LOCAL_CURSOR_DEVELOPMENT: @@ -705,6 +731,9 @@ def _build_endpoint_name(request: Request, *, fallback: str | None = None) -> st # e.g., "/collections/{readable_id}/sources/{source_short_name}" return route.path + if _native_request(request): + return fallback if fallback is not None else "/native/sources/{native_path}" + # Fallback to raw path if route not available (shouldn't happen in normal operation) return fallback if fallback is not None else request.url.path.rstrip("/") @@ -732,14 +761,14 @@ async def _track_api_call_async( properties = { "endpoint": endpoint, "request_method": request.method, - "request_path": request.url.path, + "request_path": _observable_url(request) if _native_request(request) else request.url.path, "status_code": response.status_code, "duration_ms": duration_ms, } # Flatten all path parameters directly into properties # Convert all values to strings to ensure JSON serialization works - if request.path_params: + if request.path_params and not _native_request(request): properties.update({k: str(v) for k, v in request.path_params.items()}) # Determine event name diff --git a/backend/airweave/api/tests/test_context_resolver_auth.py b/backend/airweave/api/tests/test_context_resolver_auth.py index cae054848d..0d633b9082 100644 --- a/backend/airweave/api/tests/test_context_resolver_auth.py +++ b/backend/airweave/api/tests/test_context_resolver_auth.py @@ -4,7 +4,7 @@ - Auth method dispatch (system / Auth0 / API key / none) - Auth0 cache hit returns cached user without DB call - Auth0 cache miss populates cache after DB call -- API key cache hit returns org_id without DB call +- Cached key mappings never bypass credential validation - No auth raises 401 """ @@ -38,14 +38,17 @@ class TestAuthDispatch: @pytest.mark.asyncio @patch("airweave.api.context_resolver.settings") async def test_auth_disabled_uses_system(self, mock_settings): - mock_settings.AUTH_ENABLED = False + mock_settings.AUTH_MODE = "local" mock_settings.FIRST_SUPERUSER = "admin@test.com" resolver = _make_resolver() with patch.object(resolver, "_authenticate_system", new_callable=AsyncMock) as mock_sys: mock_sys.return_value = AuthResult(method=AuthMethod.SYSTEM) result = await resolver._authenticate( - db=AsyncMock(), auth0_user=None, x_api_key=None, request=MagicMock(), + db=AsyncMock(), + auth0_user=None, + x_api_key=None, + request=MagicMock(), ) mock_sys.assert_called_once() assert result.method == AuthMethod.SYSTEM @@ -53,7 +56,7 @@ async def test_auth_disabled_uses_system(self, mock_settings): @pytest.mark.asyncio @patch("airweave.api.context_resolver.settings") async def test_auth0_user_takes_priority_over_api_key(self, mock_settings): - mock_settings.AUTH_ENABLED = True + mock_settings.AUTH_MODE = "auth0" resolver = _make_resolver() auth0_user = MagicMock(email="user@test.com", id="auth0|123") @@ -61,7 +64,10 @@ async def test_auth0_user_takes_priority_over_api_key(self, mock_settings): with patch.object(resolver, "_authenticate_auth0", new_callable=AsyncMock) as mock_auth0: mock_auth0.return_value = AuthResult(method=AuthMethod.AUTH0) result = await resolver._authenticate( - db=AsyncMock(), auth0_user=auth0_user, x_api_key="some-key", request=MagicMock(), + db=AsyncMock(), + auth0_user=auth0_user, + x_api_key="some-key", + request=MagicMock(), ) mock_auth0.assert_called_once() assert result.method == AuthMethod.AUTH0 @@ -69,15 +75,19 @@ async def test_auth0_user_takes_priority_over_api_key(self, mock_settings): @pytest.mark.asyncio @patch("airweave.api.context_resolver.settings") async def test_api_key_used_when_no_auth0_user(self, mock_settings): - mock_settings.AUTH_ENABLED = True + mock_settings.AUTH_MODE = "auth0" resolver = _make_resolver() with patch.object(resolver, "_authenticate_api_key", new_callable=AsyncMock) as mock_api: mock_api.return_value = AuthResult( - method=AuthMethod.API_KEY, api_key_org_id=str(ORG_ID), + method=AuthMethod.API_KEY, + api_key_org_id=str(ORG_ID), ) result = await resolver._authenticate( - db=AsyncMock(), auth0_user=None, x_api_key="key-123", request=MagicMock(), + db=AsyncMock(), + auth0_user=None, + x_api_key="key-123", + request=MagicMock(), ) mock_api.assert_called_once() assert result.method == AuthMethod.API_KEY @@ -85,12 +95,15 @@ async def test_api_key_used_when_no_auth0_user(self, mock_settings): @pytest.mark.asyncio @patch("airweave.api.context_resolver.settings") async def test_no_auth_raises_401(self, mock_settings): - mock_settings.AUTH_ENABLED = True + mock_settings.AUTH_MODE = "auth0" resolver = _make_resolver() with pytest.raises(HTTPException) as exc: await resolver._authenticate( - db=AsyncMock(), auth0_user=None, x_api_key=None, request=MagicMock(), + db=AsyncMock(), + auth0_user=None, + x_api_key=None, + request=MagicMock(), ) assert exc.value.status_code == 401 @@ -151,19 +164,22 @@ async def test_cache_miss_db_returns_none(self): assert "ghost@test.com" not in cache._users -class TestApiKeyCacheIntegration: - """Verify API key auth uses the cache correctly.""" +class TestApiKeyValidation: + """A cached org mapping is not authorization after key revocation.""" @pytest.mark.asyncio - async def test_cache_hit_returns_org_id(self): + async def test_stale_cache_does_not_authorize_revoked_key(self): + from airweave.core.exceptions import NotFoundException + cache = FakeContextCache() cache._api_keys["my-secret-key"] = ORG_ID - resolver = _make_resolver(cache=cache) - result = await resolver._authenticate_api_key( - db=AsyncMock(), api_key="my-secret-key", request=MagicMock(), - ) - - assert result.method == AuthMethod.API_KEY - assert result.api_key_org_id == str(ORG_ID) - assert result.metadata["api_key_id"] == "cached" + resolver._api_keys.get_by_key = AsyncMock(side_effect=NotFoundException("revoked")) + with pytest.raises(HTTPException) as error: + await resolver._authenticate_api_key( + db=AsyncMock(), + api_key="my-secret-key", + request=MagicMock(), + ) + assert error.value.status_code == 403 + resolver._api_keys.get_by_key.assert_awaited_once() diff --git a/backend/airweave/api/tests/test_service_auth.py b/backend/airweave/api/tests/test_service_auth.py new file mode 100644 index 0000000000..850bbfccee --- /dev/null +++ b/backend/airweave/api/tests/test_service_auth.py @@ -0,0 +1,177 @@ +"""Service-key mode never falls back to mock users or stale credential caches.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from airweave.api.context_resolver import AuthResult, ContextResolver +from airweave.core.config import AuthMode, settings +from airweave.core.config.settings import Settings +from airweave.core.exceptions import NotFoundException, PermissionException +from airweave.core.shared_models import AuthMethod + + +def resolver(): + return ContextResolver( + cache=MagicMock(), + rate_limiter=MagicMock(), + user_repo=MagicMock(), + api_key_repo=SimpleNamespace(get_by_key=AsyncMock()), + org_repo=MagicMock(), + ) + + +def test_auth_mode_defaults_and_legacy_migration(): + assert Settings.model_fields["AUTH_MODE"].default == AuthMode.API_KEY + assert Settings.migrate_auth_mode({"AUTH_ENABLED": True})["AUTH_MODE"] == "auth0" + with pytest.raises(ValueError, match="conflicts"): + Settings.migrate_auth_mode({"AUTH_ENABLED": False, "AUTH_MODE": "api_key"}) + + +def test_local_mode_cannot_run_in_production(): + configuration = settings.model_dump() + configuration.update(AUTH_MODE="local", AUTH_ENABLED=None, ENVIRONMENT="prd") + with pytest.raises(ValueError, match="only allowed"): + Settings(**configuration) + + +@pytest.mark.asyncio +async def test_api_key_mode_rejects_missing_key_and_user_auth(monkeypatch): + monkeypatch.setattr(settings, "AUTH_MODE", AuthMode.API_KEY) + service = resolver() + with pytest.raises(HTTPException) as error: + await service._authenticate( + MagicMock(), SimpleNamespace(email="fake@example.com"), None, MagicMock() + ) + assert error.value.status_code == 401 + with pytest.raises(HTTPException) as error: + await service.authenticate_user_only(MagicMock(), SimpleNamespace(email="fake@example.com")) + assert error.value.status_code == 401 + service._users.get_by_email.assert_not_called() + + +@pytest.mark.asyncio +async def test_key_validated_each_request_and_real_identity_preserved(): + service = resolver() + org, key = uuid4(), uuid4() + service._api_keys.get_by_key.return_value = SimpleNamespace( + organization_id=org, id=key, created_by_email="operator@example.com" + ) + request = MagicMock() + request.url.path = "/search" + first = await service._authenticate_api_key(MagicMock(), "opaque-key", request) + assert first.api_key_org_id == str(org) + assert first.metadata["api_key_id"] == str(key) + service._api_keys.get_by_key.side_effect = NotFoundException("revoked") + with pytest.raises(HTTPException) as error: + await service._authenticate_api_key(MagicMock(), "opaque-key", request) + assert error.value.status_code == 403 + assert service._api_keys.get_by_key.await_count == 2 + service._cache.get_api_key_org_id.assert_not_called() + + +@pytest.mark.asyncio +async def test_expired_key_maps_to_auth_error(): + service = resolver() + service._api_keys.get_by_key.side_effect = PermissionException("API key has expired") + with pytest.raises(HTTPException) as error: + await service._authenticate_api_key(MagicMock(), "opaque-key", MagicMock()) + assert error.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_key_cannot_override_organization(): + service = resolver() + auth = AuthResult(method=AuthMethod.API_KEY, api_key_org_id=str(uuid4())) + with pytest.raises(HTTPException) as error: + await service._validate_organization_access(MagicMock(), str(uuid4()), auth, "opaque-key") + assert error.value.status_code == 403 + service._api_keys.get_by_key.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_api_key_mode_user_dependencies_never_supply_demo_identity(monkeypatch): + from airweave.api import auth, deps + + monkeypatch.setattr(settings, "AUTH_MODE", AuthMode.API_KEY) + assert await auth.get_user_from_token("anything") is None + assert await auth.auth0.get_user() is None + assert await deps.get_user_from_token("Bearer anything", MagicMock()) is None + + +@pytest.mark.asyncio +async def test_service_bootstrap_refuses_demo_superuser(monkeypatch): + from airweave.db import bootstrap + + monkeypatch.setattr(settings, "AUTH_MODE", AuthMode.API_KEY) + session_factory = MagicMock() + monkeypatch.setattr(bootstrap, "AsyncSessionLocal", session_factory) + with pytest.raises(ValueError, match="requires AUTH_MODE=local"): + await bootstrap.bootstrap(local_superuser=True) + session_factory.assert_not_called() + + +def test_api_key_configuration_does_not_require_auth0(): + configuration = settings.model_dump() + configuration.update(AUTH_MODE="api_key", AUTH_ENABLED=None) + for field in configuration: + if field.startswith("AUTH0_"): + configuration[field] = None + assert Settings(**configuration).AUTH_MODE == AuthMode.API_KEY + configuration["AUTH_MODE"] = "auth0" + with pytest.raises(ValueError, match="must be set when AUTH_MODE=auth0"): + Settings(**configuration) + + +@pytest.mark.parametrize("value", [True, "true", "yes", "on", "1"]) +def test_legacy_truthy_auth_never_becomes_local(value): + assert Settings.migrate_auth_mode({"AUTH_ENABLED": value})["AUTH_MODE"] == AuthMode.AUTH0 + + +@pytest.mark.parametrize("value", ["typo", "", "sometimes"]) +def test_invalid_legacy_auth_rejected(value): + with pytest.raises(ValueError): + Settings.migrate_auth_mode({"AUTH_ENABLED": value}) + + +def test_service_settings_require_no_demo_credentials(): + configuration = settings.model_dump() + configuration.update( + AUTH_MODE="api_key", + AUTH_ENABLED=None, + FIRST_SUPERUSER=None, + FIRST_SUPERUSER_PASSWORD=None, + ) + result = Settings(**configuration) + assert result.FIRST_SUPERUSER is None + assert result.FIRST_SUPERUSER_PASSWORD is None + + +@pytest.mark.asyncio +async def test_startup_missing_embedding_metadata_never_seeds(): + from airweave.domains.embedders import config + + result = MagicMock() + result.scalar_one_or_none.return_value = None + db = MagicMock(execute=AsyncMock(return_value=result), commit=AsyncMock()) + with pytest.raises(config.EmbeddingConfigError, match="bootstrap"): + await config.validate_embedding_config(db) + db.add.assert_not_called() + db.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_explicit_bootstrap_initializes_embedding_metadata(): + from airweave.domains.embedders import config + + result = MagicMock() + result.scalar_one_or_none.return_value = None + db = MagicMock(execute=AsyncMock(return_value=result), commit=AsyncMock()) + await config.initialize_embedding_config(db) + row = db.add.call_args.args[0] + assert row.dense_embedder == config.DENSE_EMBEDDER + assert row.embedding_dimensions == config.EMBEDDING_DIMENSIONS + db.commit.assert_awaited_once() diff --git a/backend/airweave/api/tests/test_sync_authorization.py b/backend/airweave/api/tests/test_sync_authorization.py new file mode 100644 index 0000000000..0463411295 --- /dev/null +++ b/backend/airweave/api/tests/test_sync_authorization.py @@ -0,0 +1,39 @@ +"""An authenticated key cannot subscribe to another organization's job topics.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from airweave.api.v1.endpoints import sync + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handler", [sync.subscribe_sync_job, sync.subscribe_entity_state]) +async def test_foreign_job_never_subscribes(monkeypatch, handler): + owner, foreign_owner, job_id = uuid4(), uuid4(), uuid4() + db = object() + ctx = SimpleNamespace(organization=SimpleNamespace(id=owner)) + + async def scoped_get(session, *, id, ctx): + assert session is db + assert id == job_id + return SimpleNamespace(id=id) if ctx.organization.id == foreign_owner else None + + monkeypatch.setattr(sync.crud.sync_job, "get", scoped_get) + pubsub = SimpleNamespace(subscribe=AsyncMock()) + with pytest.raises(HTTPException) as error: + await handler(job_id=job_id, ctx=ctx, db=db, pubsub=pubsub) + assert error.value.status_code == 404 + pubsub.subscribe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authorized_job_passes_context_to_scoped_repository(monkeypatch): + get = AsyncMock(return_value=object()) + monkeypatch.setattr(sync.crud.sync_job, "get", get) + db, ctx, job_id = object(), object(), uuid4() + await sync.authorize_job(db, ctx, job_id) + get.assert_awaited_once_with(db, id=job_id, ctx=ctx) diff --git a/backend/airweave/api/v1/api.py b/backend/airweave/api/v1/api.py index 70b86c8d8c..0b03c530ba 100644 --- a/backend/airweave/api/v1/api.py +++ b/backend/airweave/api/v1/api.py @@ -7,13 +7,18 @@ auth_providers, billing, browse_tree, + calendar_records, collections, connect, entities, entity_counts, file_retrieval, health, + native_imports, + native_sources, organizations, + owned_provisioning, + records, search, search_legacy, source_connections, @@ -47,6 +52,7 @@ source_rate_limits.router, prefix="/source-rate-limits", tags=["source-rate-limits"] ) api_router.include_router(sync.router, prefix="/sync", tags=["sync"]) +api_router.include_router(records.router, prefix="/sync", tags=["records"]) api_router.include_router(entities.router, prefix="/entities", tags=["entities"]) api_router.include_router(entity_counts.router, prefix="/entity-counts", tags=["entity-counts"]) api_router.include_router(file_retrieval.router, prefix="/files", tags=["files"]) @@ -59,3 +65,12 @@ ) api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) api_router.include_router(connect.router, prefix="/connect", tags=["connect"]) +api_router.include_router(native_imports.router, prefix="/native/sources", tags=["native-imports"]) +api_router.include_router(native_sources.router, prefix="/native/sources", tags=["native-sources"]) + + +api_router.include_router(calendar_records.router, prefix="/sync", tags=["records"]) + +api_router.include_router( + owned_provisioning.router, prefix="/owned-sources", tags=["owned-sources"] +) diff --git a/backend/airweave/api/v1/endpoints/admin.py b/backend/airweave/api/v1/endpoints/admin.py index 6718fa4f1e..1ac61933c7 100644 --- a/backend/airweave/api/v1/endpoints/admin.py +++ b/backend/airweave/api/v1/endpoints/admin.py @@ -1212,7 +1212,11 @@ async def resync_with_execution_config( ctx.logger.info(f"Admin resync job will be tagged with: {tags}") async with UnitOfWork(db) as uow: + from airweave.crud.crud_sync_job import lock_sync_for_job + + locked_sync = await lock_sync_for_job(uow.session, sync_obj.organization_id, sync_id) sync_job_obj = SyncJob( + provisioning_generation=locked_sync.provisioning_generation, sync_id=sync_id, organization_id=sync_obj.organization_id, status=SyncJobStatus.PENDING, diff --git a/backend/airweave/api/v1/endpoints/calendar_records.py b/backend/airweave/api/v1/endpoints/calendar_records.py new file mode 100644 index 0000000000..26f3846c28 --- /dev/null +++ b/backend/airweave/api/v1/endpoints/calendar_records.py @@ -0,0 +1,83 @@ +"""Calendar range reads use captured provider instances, never live provider requests.""" + +from uuid import UUID +from zoneinfo import ZoneInfoNotFoundError + +from fastapi import Depends, HTTPException, Path, Query +from pydantic import AwareDatetime, ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api import deps +from airweave.api.context import ApiContext +from airweave.api.router import TrailingSlashRouter +from airweave.core.config import settings +from airweave.db.session import get_db +from airweave.domains.entities.canonical.calendar_exact import read_event +from airweave.domains.entities.canonical.calendar_query import ( + CalendarRange, + CalendarRangePage, + CalendarRangeService, +) +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.store import SourceNotFound +from airweave.models.source_connection import SourceConnection + +router = TrailingSlashRouter() + + +async def _authorized_connection(db: AsyncSession, organization_id: UUID, sync_id: UUID): + identity = await db.scalar( + select(SourceConnection.id).where( + SourceConnection.organization_id == organization_id, + SourceConnection.sync_id == sync_id, + SourceConnection.short_name == "google_calendar", + SourceConnection.is_authenticated.is_(True), + ) + ) + if identity is None: + raise SourceNotFound("Calendar source is not available") + return identity + + +@router.get("/{sync_id}/calendar/events", response_model=CalendarRangePage) +async def calendar_events( + sync_id: UUID, + calendar_id: str = Query(min_length=1), + start: AwareDatetime = Query(), + end: AwareDatetime = Query(), + timezone: str = "UTC", + limit: int = Query(100, ge=1, le=250), + cursor: str | None = None, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), +) -> CalendarRangePage: + """List observed occurrences; requests beyond coverage include an explicit sync action.""" + try: + query = CalendarRange(start=start, end=end, timezone=timezone, limit=limit, cursor=cursor) + except (ValidationError, ZoneInfoNotFoundError) as exc: + raise HTTPException(422, "Invalid timezone or window; reads are limited to31days") from exc + connection_id = await _authorized_connection(db, ctx.organization.id, sync_id) + result = await CalendarRangeService(settings.STATE_SECRET).read( + db, ctx.organization.id, sync_id, calendar_id, query + ) + + if await _authorized_connection(db, ctx.organization.id, sync_id) != connection_id: + raise SourceNotFound("Calendar source changed while reading") + return result + + +@router.get("/{sync_id}/calendar/events/{event_id}", response_model=SourceRecord) +async def calendar_event( + sync_id: UUID, + event_id: str = Path(min_length=1, max_length=1024), + calendar_id: str = Query(min_length=1, max_length=1024), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), +) -> SourceRecord: + """Read one captured provider identity; no live lookup or window expansion.""" + connection = await _authorized_connection(db, ctx.organization.id, sync_id) + result = await read_event(db, ctx.organization.id, sync_id, calendar_id, event_id) + if await _authorized_connection(db, ctx.organization.id, sync_id) != connection: + raise SourceNotFound("Calendar source changed while reading") + return result diff --git a/backend/airweave/api/v1/endpoints/health.py b/backend/airweave/api/v1/endpoints/health.py index d65953d46a..261f01d193 100644 --- a/backend/airweave/api/v1/endpoints/health.py +++ b/backend/airweave/api/v1/endpoints/health.py @@ -39,9 +39,8 @@ async def readiness( ) -> ReadinessResponse: """Readiness probe — checks critical dependencies. - Only critical probes (Postgres) gate the HTTP status code. Informational - probes (Redis, Temporal) are reported for observability but do not cause - a 503. + Configured critical probes must report up. Informational probes are reported + for observability but do not cause a 503. """ result = await health.check_readiness(debug=settings.DEBUG) diff --git a/backend/airweave/api/v1/endpoints/native_imports.py b/backend/airweave/api/v1/endpoints/native_imports.py new file mode 100644 index 0000000000..597966ef1c --- /dev/null +++ b/backend/airweave/api/v1/endpoints/native_imports.py @@ -0,0 +1,228 @@ +"""Backend-only native import intent/recovery; no publisher-controlled fences.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, HTTPException, Path +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api import deps +from airweave.api.backend_actor import backend_actor +from airweave.api.context import ApiContext +from airweave.api.router import TrailingSlashRouter +from airweave.core.container import Container +from airweave.db.session import get_db +from airweave.domains.entities.canonical.store import CanonicalStoreError, WriterBusy +from airweave.domains.native_ingestion.access_models import NativeAccessChange, NativeRecordAccess +from airweave.domains.native_ingestion.errors import NativeAdmissionError, NativeImportNotFound +from airweave.domains.native_ingestion.import_models import NativeImportState, StartNativeImport +from airweave.domains.native_ingestion.page_models import CommitNativePage, NativePageAck +from airweave.domains.native_ingestion.scope_models import ( + BeginNativeScope, + NativeScopeRef, + NativeScopeState, + ReconcileNativeScope, +) + +router = TrailingSlashRouter() +RequestKey = Annotated[str, Path(min_length=1, max_length=128)] + + +@router.put("/{source_id}/imports/{request_key}", response_model=NativeImportState) +async def start_native_import( + source_id: UUID, + request_key: RequestKey, + request: StartNativeImport, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeImportState: + """Start or recover identical intent in the authenticated organization.""" + try: + return await container.native_imports.start( + db, ctx.organization.id, source_id, request_key, request + ) + except (NativeAdmissionError, WriterBusy) as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.get("/{source_id}/imports/{request_key}", response_model=NativeImportState) +async def read_native_import( + source_id: UUID, + request_key: RequestKey, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeImportState: + """Read import state without activating any writer.""" + try: + return await container.native_imports.read(db, ctx.organization.id, source_id, request_key) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except NativeAdmissionError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.put("/{source_id}/imports/{request_key}/scopes", response_model=NativeScopeState) +async def begin_scope( + source_id: UUID, + request_key: RequestKey, + request: BeginNativeScope, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeScopeState: + """Begin scope using server-held import authority.""" + try: + return await container.native_imports.begin_scope( + db, ctx.organization.id, source_id, request_key, request + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.post("/{source_id}/imports/{request_key}/scopes/read", response_model=NativeScopeState) +async def read_scope( + source_id: UUID, + request_key: RequestKey, + request: NativeScopeRef, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeScopeState: + """Read scope using server-held import authority.""" + try: + return await container.native_imports.read_scope( + db, ctx.organization.id, source_id, request_key, request + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.post("/{source_id}/imports/{request_key}/scopes/reconcile", response_model=NativeScopeState) +async def reconcile_scope( + source_id: UUID, + request_key: RequestKey, + request: ReconcileNativeScope, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeScopeState: + """Reconcile scope using server-held import authority.""" + try: + return await container.native_imports.reconcile_scope( + db, ctx.organization.id, source_id, request_key, request + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.put("/{source_id}/imports/{request_key}/pages", response_model=NativePageAck) +async def page( + source_id: UUID, + request_key: RequestKey, + request: CommitNativePage, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativePageAck: + """Page using server-held import authority.""" + try: + return await container.native_imports.page( + db, ctx.organization.id, source_id, request_key, request + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.post("/{source_id}/imports/{request_key}/complete", response_model=NativeImportState) +async def complete_native_import( + source_id: UUID, + request_key: RequestKey, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeImportState: + """Complete this authorized import; never change another writer.""" + try: + return await container.native_imports.finish( + db, ctx.organization.id, source_id, request_key, cancel=False + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.post("/{source_id}/imports/{request_key}/cancel", response_model=NativeImportState) +async def cancel_native_import( + source_id: UUID, + request_key: RequestKey, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeImportState: + """Cancel this authorized import; never change another writer.""" + try: + return await container.native_imports.finish( + db, ctx.organization.id, source_id, request_key, cancel=True + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.get( + "/{source_id}/imports/{request_key}/records/{record_id}/access", + response_model=NativeRecordAccess, +) +async def read_access( + source_id: UUID, + request_key: RequestKey, + record_id: UUID, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeRecordAccess: + """Read access metadata under the authenticated native source binding.""" + try: + return await container.native_imports.read_access( + db, ctx.organization.id, source_id, request_key, record_id + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error + + +@router.post( + "/{source_id}/imports/{request_key}/records/{record_id}/access", + response_model=NativeRecordAccess, +) +async def change_access( + source_id: UUID, + request_key: RequestKey, + record_id: UUID, + request: NativeAccessChange, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeRecordAccess: + """Accept explicit fresh source evidence; never restore from an old page retry.""" + try: + return await container.native_imports.change_access( + db, ctx.organization.id, source_id, request_key, record_id, request + ) + except NativeImportNotFound as error: + raise HTTPException(404, {"code": error.code, "message": str(error)}) from error + except CanonicalStoreError as error: + raise HTTPException(409, {"code": error.code, "message": str(error)}) from error diff --git a/backend/airweave/api/v1/endpoints/native_sources.py b/backend/airweave/api/v1/endpoints/native_sources.py new file mode 100644 index 0000000000..0af6ffc50a --- /dev/null +++ b/backend/airweave/api/v1/endpoints/native_sources.py @@ -0,0 +1,38 @@ +"""Backend-key native source binding; no OAuth, imports or provider execution.""" + +from uuid import UUID + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api import deps +from airweave.api.backend_actor import backend_actor +from airweave.api.context import ApiContext +from airweave.api.router import TrailingSlashRouter +from airweave.core.container import Container +from airweave.db.session import get_db +from airweave.domains.native_ingestion.source_models import EnsureNativeSource, NativeSource + +router = TrailingSlashRouter() + + +@router.put("", response_model=NativeSource) +async def ensure_native_source( + request: EnsureNativeSource, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeSource: + """Ensure one immutable owner/dataset binding in the authenticated organization.""" + return await container.native_sources.ensure(db, ctx.organization.id, request) + + +@router.get("/{source_id}", response_model=NativeSource) +async def read_native_source( + source_id: UUID, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> NativeSource: + """Read the existing binding without starting synchronization.""" + return await container.native_sources.get(db, ctx.organization.id, source_id) diff --git a/backend/airweave/api/v1/endpoints/owned_provisioning.py b/backend/airweave/api/v1/endpoints/owned_provisioning.py new file mode 100644 index 0000000000..aee9117771 --- /dev/null +++ b/backend/airweave/api/v1/endpoints/owned_provisioning.py @@ -0,0 +1,39 @@ +"""Backend-only account provisioning; org keys retain their existing broad authority.""" + +from uuid import UUID + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api import deps +from airweave.api.backend_actor import backend_actor +from airweave.api.context import ApiContext +from airweave.api.router import TrailingSlashRouter +from airweave.core.container import Container +from airweave.db.session import get_db +from airweave.domains.owned_provisioning.models import EnsureSource, ProvisionedSource + +router = TrailingSlashRouter() + + +@router.put("/{account_id}", response_model=ProvisionedSource) +async def ensure_owned_source( + account_id: UUID, + request: EnsureSource, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> ProvisionedSource: + """Retry the identical generation after uncertain outcomes; IDs remain stable.""" + return await container.owned_provisioning.ensure(db, ctx, account_id, request) + + +@router.get("/{account_id}", response_model=ProvisionedSource) +async def read_owned_source( + account_id: UUID, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(backend_actor), + container: Container = Depends(deps.get_container), +) -> ProvisionedSource: + """Read provisioning state without contacting the provider or starting capture.""" + return await container.owned_provisioning.get(db, ctx, account_id) diff --git a/backend/airweave/api/v1/endpoints/records.py b/backend/airweave/api/v1/endpoints/records.py new file mode 100644 index 0000000000..075855df55 --- /dev/null +++ b/backend/airweave/api/v1/endpoints/records.py @@ -0,0 +1,313 @@ +"""Internal service API for committed source records; provider APIs are never read here.""" + +from asyncio import FIRST_COMPLETED, create_task, gather, wait +from typing import Literal +from uuid import UUID + +from fastapi import Depends, HTTPException, Path, Query, Request, Response +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from airweave.api import deps +from airweave.api.context import ApiContext +from airweave.api.router import TrailingSlashRouter +from airweave.core.container import Container +from airweave.db.session import get_db +from airweave.domains.entities.canonical.calendar_query import CalendarRangeNotCaptured +from airweave.domains.entities.canonical.projection_store import current_extraction +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_models import ( + DocumentRead, + IndexedRecordRead, + MailThreadPage, + RecordChangePage, + RecordFilters, + RecordListQuery, + RecordPage, + SpreadsheetRead, +) +from airweave.domains.entities.canonical.store import CanonicalStoreError +from airweave.domains.entities.canonical.text_models import TextRead, TextRepresentationList +from airweave.domains.search.owned_models import OwnedSearchRequest, OwnedSearchResponse +from airweave.platform.sources.records.sheets_manifest import GridBounds + +router = TrailingSlashRouter() + + +async def _search_disconnected(request: Request) -> None: + # FastAPI has consumed this read-only search POST's body before entering the route. + while (await request.receive())["type"] != "http.disconnect": + pass + + +@router.post("/search", response_model=OwnedSearchResponse) +async def search_records( + request: OwnedSearchRequest, + http_request: Request, + sessions: async_sessionmaker[AsyncSession] = Depends(deps.get_search_session_factory), + ctx: ApiContext = Depends(deps.get_owned_search_context), + container: Container = Depends(deps.get_container), +) -> OwnedSearchResponse: + """Retrieve bounded indexed originals; no provider requests or agent execution.""" + work = create_task(container.owned_search.search(sessions, ctx, request)) + disconnected = create_task(_search_disconnected(http_request)) + try: + done, _ = await wait((work, disconnected), return_when=FIRST_COMPLETED) + if work in done: + return await work + await disconnected + raise HTTPException(499, "Search client disconnected") + finally: + # Join async work before request-scoped dependencies close. Already-running + # synchronous Vespa or inference work cannot be forcibly stopped here. + work.cancel() + disconnected.cancel() + await gather(work, disconnected, return_exceptions=True) + + +async def record_error_response(request: Request, error: CanonicalStoreError) -> JSONResponse: + """Preserve machine-readable recovery codes without exposing SQL/provider diagnostics.""" + status = { + "source_not_found": 404, + "record_not_found": 404, + "blob_not_found": 404, + "stale_record_revision": 409, + "blob_unavailable": 503, + "document_unavailable": 409, + "spreadsheet_unavailable": 409, + "text_unavailable": 409, + "document_incomplete": 409, + "calendar_changed_restart": 409, + "calendar_read_incomplete": 409, + "calendar_range_not_captured": 409, + }.get(error.code, 400) + detail = {"code": error.code, "message": str(error), "retryable": status == 503} + if isinstance(error, CalendarRangeNotCaptured): + detail["action"] = error.action + return JSONResponse(status_code=status, content={"error": detail}) + + +@router.get("/{sync_id}/records", response_model=RecordPage) +async def list_records( + sync_id: UUID, + record_type: str | None = None, + container_id: str | None = None, + parent_record_id: UUID | None = None, + state: Literal["active", "deleted", "all"] = "active", + cursor: str | None = None, + limit: int = Query(100, ge=1, le=500), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), +) -> RecordPage: + """List exact stored records in stable ID order; continuation is a live traversal.""" + query = RecordListQuery( + filters=RecordFilters( + record_type=record_type, + container_id=container_id, + parent_record_id=parent_record_id, + state=state, + ), + cursor=cursor, + limit=limit, + ) + return await service.list_records(db, ctx.organization.id, sync_id, query) + + +@router.get("/{sync_id}/records/changes", response_model=RecordChangePage) +async def record_changes( + sync_id: UUID, + cursor: str | None = None, + limit: int = Query(100, ge=1, le=500), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), +) -> RecordChangePage: + """Read observed changes from the beginning, or resume a source-bound cursor.""" + return await service.changes(db, ctx.organization.id, sync_id, cursor=cursor, limit=limit) + + +@router.get("/{sync_id}/records/{record_id}", response_model=IndexedRecordRead) +async def read_record( + sync_id: UUID, + record_id: UUID, + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), +) -> IndexedRecordRead: + """Read current committed provider state, including tombstones and completeness.""" + record = await service.read(db, ctx.organization.id, sync_id, record_id) + extraction = await current_extraction( + db, ctx.organization.id, sync_id, record_id, record.revision + ) + return IndexedRecordRead(**record.model_dump(), extraction=extraction) + + +@router.get("/{sync_id}/records/{record_id}/spreadsheet", response_model=SpreadsheetRead) +async def read_stored_spreadsheet( + sync_id: UUID, + record_id: UUID, + response: Response, + revision: int = Query(ge=1), + sheet_id: int | None = Query(None, ge=0), + start_row: int | None = Query(None, ge=0), + end_row: int | None = Query(None, gt=0), + start_column: int | None = Query(None, ge=0), + end_column: int | None = Query(None, gt=0), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), + container: Container = Depends(deps.get_container), +) -> SpreadsheetRead: + """Read exact retained spreadsheet cells; no live provider fallback.""" + values = (sheet_id, start_row, end_row, start_column, end_column) + if any(value is not None for value in values) and any(value is None for value in values): + raise HTTPException(status_code=422, detail="Supply all grid bounds or none") + try: + bounds = ( + None + if sheet_id is None + else GridBounds( + sheet_id=sheet_id, + start_row=start_row, + end_row=end_row, + start_column=start_column, + end_column=end_column, + ) + ) + except ValueError: + raise HTTPException(status_code=422, detail="Grid bounds must be nonempty") from None + result = await service.spreadsheet( + db, ctx.organization.id, sync_id, record_id, revision, container.storage_backend, bounds + ) + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return result + + +@router.get("/{sync_id}/records/{record_id}/document", response_model=DocumentRead) +async def read_stored_document( + sync_id: UUID, + record_id: UUID, + response: Response, + revision: int = Query(ge=1), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), + container: Container = Depends(deps.get_container), +) -> DocumentRead: + """Read exact retained native Docs content; never fetch or fall back to a provider.""" + result = await service.document( + db, ctx.organization.id, sync_id, record_id, revision, container.storage_backend + ) + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return result + + +@router.get("/{sync_id}/mail/threads/{thread_id}", response_model=MailThreadPage) +async def mail_thread( + sync_id: UUID, + thread_id: str = Path(min_length=1, max_length=512), + cursor: str | None = None, + limit: int = Query(100, ge=1, le=100), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), +) -> MailThreadPage: + """Read observed Gmail messages in chronological order without querying the provider.""" + return await service.mail_thread( + db, ctx.organization.id, sync_id, thread_id, cursor=cursor, limit=limit + ) + + +@router.get("/{sync_id}/records/{record_id}/blobs/{sha256}") +async def record_blob( + sync_id: UUID, + record_id: UUID, + sha256: str = Path(pattern=r"^[a-f0-9]{64}$"), + revision: int = Query(ge=1), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), + container: Container = Depends(deps.get_container), +) -> Response: + """Return verified owned bytes only; clients cannot supply storage keys or remote URLs.""" + content = await service.blob( + db, ctx.organization.id, sync_id, record_id, revision, sha256, container.storage_backend + ) + return Response( + content, + media_type="application/octet-stream", + headers={ + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "Content-Disposition": "attachment", + "ETag": '"' + sha256 + '"', + }, + ) + + +@router.get( + "/{sync_id}/records/{record_id}/text-representations", response_model=TextRepresentationList +) +async def list_text_representations( + sync_id: UUID, + record_id: UUID, + response: Response, + revision: int = Query(ge=1), + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), + container: Container = Depends(deps.get_container), +) -> TextRepresentationList: + """Describe derived text only from the current authorized index publication.""" + from airweave.domains.entities.canonical.text_query import CanonicalTextReader + + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return await CanonicalTextReader(service, container.storage_backend).list( + db, + ctx.organization.id, + sync_id, + record_id, + revision, + ) + + +@router.get( + "/{sync_id}/records/{record_id}/text-representations/{representation_id}", + response_model=TextRead, +) +async def read_text_representation( + sync_id: UUID, + record_id: UUID, + response: Response, + representation_id: UUID, + generation: UUID, + revision: int = Query(ge=1), + offset: int = Query(0, ge=0), + limit: int = Query(12000, ge=1, le=100000), + view: Literal["content", "index"] = "content", + db: AsyncSession = Depends(get_db), + ctx: ApiContext = Depends(deps.get_context), + service: CanonicalQueryService = Depends(deps.get_canonical_query_service), + container: Container = Depends(deps.get_container), +) -> TextRead: + """Read complete retained converter text in bounded character ranges.""" + from airweave.domains.entities.canonical.text_query import CanonicalTextReader + + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return await CanonicalTextReader(service, container.storage_backend).read( + db, + ctx.organization.id, + sync_id, + record_id, + revision, + generation, + representation_id, + offset=offset, + limit=limit, + view=view, + ) diff --git a/backend/airweave/api/v1/endpoints/search.py b/backend/airweave/api/v1/endpoints/search.py index 6ccc06903c..ed06bf8b8e 100644 --- a/backend/airweave/api/v1/endpoints/search.py +++ b/backend/airweave/api/v1/endpoints/search.py @@ -79,7 +79,7 @@ async def instant_search( ) results = await service.search(db, ctx, readable_id, request) - return SearchV2Response(results=results.results) + return SearchV2Response(**results.model_dump()) @router.post( @@ -139,7 +139,7 @@ async def classic_search( ) results = await service.search(db, ctx, readable_id, request) - return SearchV2Response(results=results.results) + return SearchV2Response(**results.model_dump()) @router.post( @@ -179,7 +179,7 @@ async def agentic_search( # Service emits SearchCompletedEvent internally results = await service.search(db, ctx, readable_id, request) truncated = results.results[: request.limit] if request.limit else results.results - return SearchV2Response(results=truncated) + return SearchV2Response(**results.model_copy(update={"results": truncated}).model_dump()) # ── Streaming agentic search ────────────────────────────────────────── @@ -407,7 +407,7 @@ async def admin_instant_search_as_user( results = await service.search( db, ctx, readable_id, request, user_principal_override=user_principal ) - return SearchV2Response(results=results.results) + return SearchV2Response(**results.model_dump()) @admin_router.post( @@ -438,7 +438,7 @@ async def admin_classic_search_as_user( results = await service.search( db, ctx, readable_id, request, user_principal_override=user_principal ) - return SearchV2Response(results=results.results) + return SearchV2Response(**results.model_dump()) @admin_router.post( @@ -470,4 +470,4 @@ async def admin_agentic_search_as_user( db, ctx, readable_id, request, user_principal_override=user_principal ) truncated = results.results[: request.limit] if request.limit else results.results - return SearchV2Response(results=truncated) + return SearchV2Response(**results.model_copy(update={"results": truncated}).model_dump()) diff --git a/backend/airweave/api/v1/endpoints/sync.py b/backend/airweave/api/v1/endpoints/sync.py index 24add9764f..747c9b3ace 100644 --- a/backend/airweave/api/v1/endpoints/sync.py +++ b/backend/airweave/api/v1/endpoints/sync.py @@ -5,23 +5,33 @@ from typing import AsyncGenerator from uuid import UUID -from fastapi import Depends +from fastapi import Depends, HTTPException from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from airweave import crud from airweave.api import deps from airweave.api.context import ApiContext from airweave.api.deps import Inject from airweave.api.router import TrailingSlashRouter from airweave.core.logging import logger from airweave.core.protocols import PubSub +from airweave.db.session import get_db router = TrailingSlashRouter() +async def authorize_job(db: AsyncSession, ctx: ApiContext, job_id: UUID) -> None: + """Authorize ownership before subscribing to an otherwise unscoped pubsub topic.""" + if await crud.sync_job.get(db, id=job_id, ctx=ctx) is None: + raise HTTPException(status_code=404, detail="Sync job not found") + + @router.get("/job/{job_id}/subscribe") async def subscribe_sync_job( job_id: UUID, ctx: ApiContext = Depends(deps.get_context), + db: AsyncSession = Depends(get_db), pubsub: PubSub = Inject(PubSub), ) -> StreamingResponse: """Server-Sent Events (SSE) endpoint to subscribe to a sync job's progress. @@ -30,12 +40,15 @@ async def subscribe_sync_job( ----- job_id: The ID of the job to subscribe to ctx: The API context + db: Database session for organization-scoped job authorization pubsub: PubSub adapter for event streaming Returns: -------- StreamingResponse: The streaming response """ + await authorize_job(db, ctx, job_id) + logger.info(f"SSE sync subscription authenticated for user: {ctx}, job: {job_id}") # Track active SSE connections @@ -91,6 +104,7 @@ async def event_stream() -> AsyncGenerator[str, None]: async def subscribe_entity_state( job_id: UUID, ctx: ApiContext = Depends(deps.get_context), + db: AsyncSession = Depends(get_db), pubsub: PubSub = Inject(PubSub), ) -> StreamingResponse: """SSE endpoint for total entity state updates during sync. @@ -103,12 +117,15 @@ async def subscribe_entity_state( ----- job_id: The ID of the job to subscribe to ctx: The API context + db: Database session for organization-scoped job authorization pubsub: PubSub adapter for event streaming Returns: -------- StreamingResponse: Server-sent events with entity state updates """ + await authorize_job(db, ctx, job_id) + logger.info(f"SSE entity state subscription for user: {ctx}, job: {job_id}") channel = f"sync_job_state:{job_id}" diff --git a/backend/airweave/core/config/__init__.py b/backend/airweave/core/config/__init__.py index f0b74a7163..8df18b9b71 100644 --- a/backend/airweave/core/config/__init__.py +++ b/backend/airweave/core/config/__init__.py @@ -14,11 +14,12 @@ ... """ -from airweave.core.config.enums import Environment, StorageBackendType +from airweave.core.config.enums import AuthMode, Environment, StorageBackendType from airweave.core.config.settings import MISTRAL_DEFAULT_BASE_URL, Settings __all__ = [ "Settings", + "AuthMode", "StorageBackendType", "Environment", "settings", diff --git a/backend/airweave/core/config/enums.py b/backend/airweave/core/config/enums.py index f2b4688f7d..2bc7e33bb4 100644 --- a/backend/airweave/core/config/enums.py +++ b/backend/airweave/core/config/enums.py @@ -30,3 +30,11 @@ class Environment(str, Enum): TEST = "test" DEV = "dev" PRD = "prd" + + +class AuthMode(str, Enum): + """Authentication authority; local explicitly opts into insecure development.""" + + API_KEY = "api_key" + AUTH0 = "auth0" + LOCAL = "local" diff --git a/backend/airweave/core/config/settings.py b/backend/airweave/core/config/settings.py index c4fbc8db35..6d4d4d9248 100644 --- a/backend/airweave/core/config/settings.py +++ b/backend/airweave/core/config/settings.py @@ -5,10 +5,10 @@ from typing import Optional -from pydantic import PostgresDsn, ValidationInfo, field_validator +from pydantic import PostgresDsn, TypeAdapter, ValidationInfo, field_validator, model_validator from pydantic_settings import BaseSettings -from airweave.core.config.enums import Environment, StorageBackendType +from airweave.core.config.enums import AuthMode, Environment, StorageBackendType _BANNED_PASSWORDS: frozenset[str] = frozenset( { @@ -63,9 +63,6 @@ class Settings(BaseSettings): POSTGRES_PASSWORD (str): The PostgreSQL password. SQLALCHEMY_ASYNC_DATABASE_URI (Optional[PostgresDsn]): The SQLAlchemy async database URI. LOCAL_NGROK_SERVER (Optional[str]): The local ngrok server URL. - RUN_ALEMBIC_MIGRATIONS (bool): Whether to run the alembic migrations. - RUN_DB_SYNC (bool): Whether to run the system sync to process sources, - destinations, and entity types. REDIS_HOST (str): The Redis server hostname (single-host mode). REDIS_PORT (int): The Redis server port (single-host mode). REDIS_PASSWORD (Optional[str]): The Redis password (if authentication is enabled). @@ -108,11 +105,12 @@ class Settings(BaseSettings): # Rate limiting DISABLE_RATE_LIMIT: bool = False # For testing purposes - disables rate limiting completely - FIRST_SUPERUSER: str - FIRST_SUPERUSER_PASSWORD: str + FIRST_SUPERUSER: Optional[str] = None + FIRST_SUPERUSER_PASSWORD: Optional[str] = None FIRST_SUPERUSER_NAME: str = "Admin" - AUTH_ENABLED: Optional[bool] = False + AUTH_MODE: AuthMode = AuthMode.API_KEY + AUTH_ENABLED: Optional[bool] = None # Deprecated migration input; use AUTH_MODE. AUTH0_DOMAIN: Optional[str] = None AUTH0_AUDIENCE: Optional[str] = None AUTH0_RULE_NAMESPACE: Optional[str] = None @@ -144,8 +142,6 @@ class Settings(BaseSettings): LOCAL_NGROK_SERVER: Optional[str] = None - RUN_ALEMBIC_MIGRATIONS: bool = True - RUN_DB_SYNC: bool = True ENABLE_INTERNAL_SOURCES: bool = False # Enable internal/testing sources (stub, snapshot) # Redis configuration. @@ -220,6 +216,10 @@ class Settings(BaseSettings): # in-code default in domains/search/config.py. LLM_FALLBACK_CHAIN: Optional[str] = None + # Optional local OCR; models are provisioned at build/setup time, never during inference. + LOCAL_OCR_TESSDATA_PATH: Optional[str] = None + LOCAL_OCR_LANGUAGES: tuple[str, ...] = ("eng",) + # Docling OCR fallback service (None = disabled) DOCLING_BASE_URL: Optional[str] = None @@ -237,6 +237,7 @@ class Settings(BaseSettings): # Temporal worker graceful shutdown configuration TEMPORAL_GRACEFUL_SHUTDOWN_TIMEOUT: int = 7200 # 2 hours in seconds + WORKER_BIND_HOST: str = "0.0.0.0" # Control server and Temporal SDK metrics WORKER_METRICS_PORT: int = 8888 # Port for /drain and /health endpoints METRICS_PORT: int = 9090 # Port for Prometheus metrics endpoint METRICS_HOST: str = "0.0.0.0" # Bind address for metrics server @@ -358,6 +359,32 @@ def parse_cors_origins(cls, v: Optional[str]) -> Optional[list[str]]: return [origin.strip() for origin in v.split(",") if origin.strip()] + @model_validator(mode="before") + @classmethod + def migrate_auth_mode(cls, values): + """Preserve explicit legacy configuration without retaining two authorities.""" + values = dict(values) + legacy = values.get("AUTH_ENABLED") + if legacy is not None: + enabled = TypeAdapter(bool).validate_python(legacy) + inferred = AuthMode.AUTH0 if enabled else AuthMode.LOCAL + if "AUTH_MODE" in values and values["AUTH_MODE"] != inferred: + raise ValueError("AUTH_ENABLED conflicts with AUTH_MODE; remove AUTH_ENABLED") + values["AUTH_MODE"] = inferred + return values + + @model_validator(mode="after") + def validate_auth_mode(self): + """Insecure authentication is never available in a deployed environment.""" + if self.AUTH_MODE == AuthMode.LOCAL and self.ENVIRONMENT not in { + Environment.LOCAL, + Environment.TEST, + }: + raise ValueError("AUTH_MODE=local is only allowed in local/test environments") + if self.AUTH_MODE == AuthMode.LOCAL and not self.FIRST_SUPERUSER: + raise ValueError("FIRST_SUPERUSER is required for AUTH_MODE=local") + return self + @field_validator( "AUTH0_DOMAIN", "AUTH0_AUDIENCE", @@ -368,7 +395,7 @@ def parse_cors_origins(cls, v: Optional[str]) -> Optional[list[str]]: mode="before", ) def validate_auth0_settings(cls, v: str, info: ValidationInfo) -> str: - """Validate Auth0 settings when AUTH_ENABLED is True. + """Validate Auth0 settings only for AUTH_MODE=auth0. Args: @@ -382,12 +409,12 @@ def validate_auth0_settings(cls, v: str, info: ValidationInfo) -> str: Raises: ------ - ValueError: If AUTH_ENABLED is True and the Auth0 setting is empty. + ValueError: If AUTH_MODE=auth0 and the Auth0 setting is empty. """ - auth_enabled = info.data.get("AUTH_ENABLED", False) + auth_enabled = info.data.get("AUTH_MODE") == AuthMode.AUTH0 if auth_enabled and not v: field_name = info.field_name - raise ValueError(f"{field_name} must be set when AUTH_ENABLED is True") + raise ValueError(f"{field_name} must be set when AUTH_MODE=auth0") return v @field_validator( @@ -458,8 +485,12 @@ def validate_svix_jwt_secret(cls, v: str) -> str: return v @field_validator("FIRST_SUPERUSER_PASSWORD", mode="before") - def validate_first_superuser_password(cls, v: str, info: ValidationInfo) -> str: - """Reject weak superuser passwords in non-local environments.""" + def validate_first_superuser_password( + cls, v: Optional[str], info: ValidationInfo + ) -> Optional[str]: + """Validate bootstrap passwords when supplied; service mode needs none.""" + if v is None: + return None environment = info.data.get("ENVIRONMENT", "local") env_str = environment.value if isinstance(environment, Environment) else environment if env_str in ("local", "test"): @@ -476,7 +507,9 @@ def validate_first_superuser_password(cls, v: str, info: ValidationInfo) -> str: return v @field_validator("FIRST_SUPERUSER", mode="before") - def validate_first_superuser_email(cls, v: str, info: ValidationInfo) -> str: + def validate_first_superuser_email( + cls, v: Optional[str], info: ValidationInfo + ) -> Optional[str]: """Reject well-known placeholder emails in non-local environments.""" environment = info.data.get("ENVIRONMENT", "local") env_str = environment.value if isinstance(environment, Environment) else environment diff --git a/backend/airweave/core/config/tests/test_settings_validators.py b/backend/airweave/core/config/tests/test_settings_validators.py index e3eee42504..399871b833 100644 --- a/backend/airweave/core/config/tests/test_settings_validators.py +++ b/backend/airweave/core/config/tests/test_settings_validators.py @@ -8,6 +8,7 @@ # can override *only* the field under test and trigger the expected error. _BASE_ENV: dict[str, str] = { "ENVIRONMENT": "local", + "AUTH_MODE": "api_key", "FIRST_SUPERUSER": "test@example.com", "FIRST_SUPERUSER_PASSWORD": "testpassword123", "ENCRYPTION_KEY": "SpgLrrEEgJ/7QdhSMSvagL1juEY5eoyCG0tZN7OSQV0=", @@ -21,6 +22,8 @@ def _build_settings(monkeypatch, overrides: dict[str, str]): """Set env vars from _BASE_ENV + overrides, then construct Settings.""" + # These field-validator tests must not inherit insecure local auth from conftest. + monkeypatch.delenv("AUTH_ENABLED", raising=False) for key, value in {**_BASE_ENV, **overrides}.items(): monkeypatch.setenv(key, value) diff --git a/backend/airweave/core/container/container.py b/backend/airweave/core/container/container.py index 7958740565..ad6f2af012 100644 --- a/backend/airweave/core/container/container.py +++ b/backend/airweave/core/container/container.py @@ -62,6 +62,8 @@ EntityDefinitionRegistryProtocol, EntityRepositoryProtocol, ) +from airweave.domains.native_ingestion.import_service import NativeImports +from airweave.domains.native_ingestion.source_service import NativeSources from airweave.domains.oauth.protocols import ( OAuth1ServiceProtocol, OAuth2ServiceProtocol, @@ -74,6 +76,8 @@ OrganizationServiceProtocol, UserOrganizationRepositoryProtocol, ) +from airweave.domains.owned_provisioning.service import OwnedProvisioningService +from airweave.domains.search.owned import OwnedSearchService from airweave.domains.search.protocols import ( AgenticSearchServiceProtocol, BrowseServiceProtocol, @@ -251,6 +255,10 @@ async def my_endpoint(event_bus: EventBus = Inject(EventBus)): connect_service: ConnectServiceProtocol # Search domain (v2 tiers) + owned_search: OwnedSearchService + owned_provisioning: OwnedProvisioningService + native_sources: NativeSources + native_imports: NativeImports instant_search: InstantSearchServiceProtocol classic_search: ClassicSearchServiceProtocol agentic_search: AgenticSearchServiceProtocol diff --git a/backend/airweave/core/container/factory.py b/backend/airweave/core/container/factory.py index a1cc47bb54..d89b73fe7f 100644 --- a/backend/airweave/core/container/factory.py +++ b/backend/airweave/core/container/factory.py @@ -85,9 +85,14 @@ from airweave.domains.embedders.sparse.fastembed import ( FastEmbedSparseEmbedder as DomainFastEmbedSparseEmbedder, ) +from airweave.domains.entities.canonical.store import CanonicalRecordStore from airweave.domains.entities.entity_count_repository import EntityCountRepository from airweave.domains.entities.entity_repository import EntityRepository from airweave.domains.entities.registry import EntityDefinitionRegistry +from airweave.domains.native_ingestion.import_service import NativeImports +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.source_service import NativeSources +from airweave.domains.native_ingestion.source_store import NativeSourceStore from airweave.domains.oauth.callback_service import OAuthCallbackService from airweave.domains.oauth.flow_service import OAuthFlowService from airweave.domains.oauth.oauth1_service import OAuth1Service @@ -98,11 +103,14 @@ ) from airweave.domains.ocr.docling import DoclingOcrAdapter from airweave.domains.ocr.fallback import FallbackOcrProvider +from airweave.domains.ocr.local import LocalOcrProvider from airweave.domains.ocr.mistral.converter import MistralOCR from airweave.domains.ocr.protocols import OcrProvider from airweave.domains.organizations.protocols import UserOrganizationRepositoryProtocol from airweave.domains.organizations.repository import OrganizationRepository as OrgRepo from airweave.domains.organizations.repository import UserOrganizationRepository +from airweave.domains.owned_provisioning.service import OwnedProvisioningService +from airweave.domains.owned_provisioning.store import ProvisioningStore from airweave.domains.search.adapters.vector_db.filter_translator import FilterTranslator from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB from airweave.domains.search.agentic.service import AgenticSearchService @@ -113,6 +121,7 @@ from airweave.domains.search.config import SearchConfig from airweave.domains.search.executor import SearchPlanExecutor from airweave.domains.search.instant.service import InstantSearchService +from airweave.domains.search.owned import OwnedSearchService from airweave.domains.source_connections.create import SourceConnectionCreationService from airweave.domains.source_connections.delete import SourceConnectionDeletionService from airweave.domains.source_connections.repository import SourceConnectionRepository @@ -613,6 +622,19 @@ def create_container(settings: Settings) -> Container: organization_service=org_service, user_service=user_service, email_service=email_service, + owned_search=search_deps["owned_search"], + native_sources=NativeSources(NativeSourceStore()), + native_imports=NativeImports( + NativeImportStore(NativeSourceStore(), CanonicalRecordStore()) + ), + owned_provisioning=OwnedProvisioningService( + store=ProvisioningStore(create_service, source_validation), + lifecycle=source_deps["source_lifecycle_service"], + jobs=source_deps["sync_job_repo"], + syncs=source_deps["sync_repo"], + schedules=sync_deps["temporal_schedule_service"], + workflows=sync_deps["temporal_workflow_service"], + ), instant_search=search_deps["instant_search"], classic_search=search_deps["classic_search"], agentic_search=search_deps["agentic_search"], @@ -748,18 +770,28 @@ def _create_ocr_provider( ) -> Optional[OcrProvider]: """Create OCR provider with fallback chain. - Chain order: Mistral (cloud) -> Docling (local service, if configured). + Chain order: local Tesseract (if configured) -> Mistral -> Docling. Docling is only added when DOCLING_BASE_URL is set. Returns None with a warning when no providers are available. """ + providers = [] + if settings.LOCAL_OCR_TESSDATA_PATH: + providers.append( + ( + "local-tesseract", + LocalOcrProvider( + tessdata_path=settings.LOCAL_OCR_TESSDATA_PATH, + languages=settings.LOCAL_OCR_LANGUAGES, + ), + ) + ) try: - mistral_ocr = MistralOCR() + mistral_ocr = MistralOCR() if settings.MISTRAL_API_KEY else None except Exception as e: logger.error(f"Error creating Mistral OCR adapter: {e}") mistral_ocr = None - providers = [] if mistral_ocr: providers.append(("mistral-ocr", mistral_ocr)) @@ -774,7 +806,7 @@ def _create_ocr_provider( if not providers: logger.warning( "No OCR providers available — document processing will be disabled. " - "Set MISTRAL_API_KEY or DOCLING_BASE_URL to enable OCR." + "Set LOCAL_OCR_TESSDATA_PATH, MISTRAL_API_KEY or DOCLING_BASE_URL to enable OCR." ) return None @@ -791,12 +823,20 @@ def _create_dense_embedder( Uses the domain config constants (DENSE_EMBEDDER, EMBEDDING_DIMENSIONS) and the registry to look up the spec and construct the correct embedder. """ + from airweave.domains.embedders.dense.cohere import CohereDenseEmbedder from airweave.domains.embedders.dense.local import LocalDenseEmbedder from airweave.domains.embedders.dense.mistral import MistralDenseEmbedder from airweave.domains.embedders.dense.openai import OpenAIDenseEmbedder spec = registry.get(DENSE_EMBEDDER) + if spec.embedder_class_ref is CohereDenseEmbedder: + return CohereDenseEmbedder( + api_key=settings.COHERE_API_KEY, + model=spec.api_model_name, + dimensions=EMBEDDING_DIMENSIONS, + ) + if spec.embedder_class_ref is OpenAIDenseEmbedder: return OpenAIDenseEmbedder( api_key=settings.OPENAI_API_KEY, @@ -1079,7 +1119,7 @@ def _create_usage_ledger(settings: Settings, billing_deps: dict) -> UsageLedgerP def _create_identity_provider(settings: Settings) -> IdentityProvider: """Create identity provider: Auth0 if enabled, otherwise null implementation.""" - if settings.AUTH_ENABLED: + if settings.AUTH_MODE == "auth0": from airweave.adapters.identity.auth0 import auth0_management_client if auth0_management_client: @@ -1354,16 +1394,21 @@ def _create_search_services( reranker=reranker, executor=executor, vector_db=vector_db, + source_registry=source_registry, metadata_builder=metadata_builder, collection_repo=collection_repo, event_bus=event_bus, ) browse_service = BrowseService( vector_db=vector_db, + source_registry=source_registry, collection_repo=collection_repo, ) return { + "owned_search": OwnedSearchService( + executor, source_registry, reranker=reranker, tokenizer=tokenizer + ), "instant_search": instant_search, "classic_search": classic_search, "agentic_search": agentic_search, diff --git a/backend/airweave/core/container/tests/test_ocr_wiring.py b/backend/airweave/core/container/tests/test_ocr_wiring.py new file mode 100644 index 0000000000..6701ef5d5c --- /dev/null +++ b/backend/airweave/core/container/tests/test_ocr_wiring.py @@ -0,0 +1,39 @@ +"""Local OCR is opt-in and precedes metered fallback without duplicate processing.""" + +from unittest.mock import patch + +import pytest + +from airweave.adapters.circuit_breaker.fake import FakeCircuitBreaker +from airweave.core.config.settings import Settings +from airweave.core.container.factory import _create_ocr_provider +from airweave.domains.ocr.fakes.provider import FakeOcrProvider + + +def test_no_configured_ocr_remains_disabled(): + settings = Settings.model_construct( + LOCAL_OCR_TESSDATA_PATH=None, MISTRAL_API_KEY=None, DOCLING_BASE_URL=None + ) + assert _create_ocr_provider(FakeCircuitBreaker(), settings) is None + + +@pytest.mark.asyncio +async def test_local_ocr_precedes_cloud_and_receives_configured_languages(): + settings = Settings.model_construct( + LOCAL_OCR_TESSDATA_PATH="/models", + LOCAL_OCR_LANGUAGES=("eng", "hin"), + MISTRAL_API_KEY="test-only", + DOCLING_BASE_URL=None, + ) + local = FakeOcrProvider(default_markdown="local result") + cloud = FakeOcrProvider(default_markdown="cloud result") + with ( + patch( + "airweave.core.container.factory.LocalOcrProvider", return_value=local + ) as constructor, + patch("airweave.core.container.factory.MistralOCR", return_value=cloud), + ): + provider = _create_ocr_provider(FakeCircuitBreaker(), settings) + constructor.assert_called_once_with(tessdata_path="/models", languages=("eng", "hin")) + assert await provider.convert_batch(["scan.pdf"]) == {"scan.pdf": "local result"} + assert cloud.calls == [] diff --git a/backend/airweave/core/health/protocols.py b/backend/airweave/core/health/protocols.py index 1e5fdf6f47..06c0ff377d 100644 --- a/backend/airweave/core/health/protocols.py +++ b/backend/airweave/core/health/protocols.py @@ -13,9 +13,9 @@ class HealthProbe(Protocol): """Protocol for a single infrastructure health check. - Implementations return a ``DependencyCheck`` on success and **raise** - on failure. The orchestrator handles timeouts and error sanitization, - so probes can stay simple. + Implementations return a ``DependencyCheck`` with the observed status or + raise when a check fails. The orchestrator handles timeouts and error + sanitization, so probes can stay simple. The critical-vs-informational distinction is a deployment concern and lives in the wiring layer, not in the probe itself. @@ -30,7 +30,7 @@ async def check(self) -> DependencyCheck: """Probe the dependency and return its status. Returns: - A ``DependencyCheck`` with ``status=up`` and measured latency. + A ``DependencyCheck`` with the observed status and measured latency. Raises: Any exception on failure — the orchestrator will catch it. diff --git a/backend/airweave/core/health/service.py b/backend/airweave/core/health/service.py index f75610baf6..52864a384d 100644 --- a/backend/airweave/core/health/service.py +++ b/backend/airweave/core/health/service.py @@ -70,8 +70,6 @@ async def check_readiness(self, *, debug: bool) -> ReadinessResponse: ) checks: dict[str, DependencyCheck] = {} - ready = True - for entry in results: if isinstance(entry, BaseException): continue @@ -83,11 +81,14 @@ async def check_readiness(self, *, debug: bool) -> ReadinessResponse: status=CheckStatus.down, error=self._sanitize_error(outcome, debug=debug), ) - if name in critical_names: - ready = False else: checks[name] = outcome + # Critical means positively verified up, including when a probe returns + # a status rather than raising or is cancelled before reporting one. + ready = all( + name in checks and checks[name].status == CheckStatus.up for name in critical_names + ) return ReadinessResponse( status="ready" if ready else "not_ready", checks=checks, diff --git a/backend/airweave/core/health/tests/test_health.py b/backend/airweave/core/health/tests/test_health.py index b3a49feddc..6a83158b9c 100644 --- a/backend/airweave/core/health/tests/test_health.py +++ b/backend/airweave/core/health/tests/test_health.py @@ -2,6 +2,7 @@ import asyncio import errno +from unittest.mock import AsyncMock import pytest @@ -12,7 +13,7 @@ FakeSlowProbe, ) from airweave.core.health.service import HealthService -from airweave.schemas.health import CheckStatus +from airweave.schemas.health import CheckStatus, DependencyCheck # --------------------------------------------------------------------------- # check_readiness @@ -22,6 +23,30 @@ class TestCheckReadiness: """Tests for the readiness orchestrator.""" + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [CheckStatus.down, CheckStatus.skipped]) + @pytest.mark.parametrize("critical", [True, False]) + async def test_returned_non_up_status_gates_only_critical_dependencies(self, status, critical): + probe = FakeProbe("temporal") + probe.check = AsyncMock(return_value=DependencyCheck(status=status)) + svc = HealthService( + critical=[probe] if critical else [], + informational=[] if critical else [probe], + ) + + result = await svc.check_readiness(debug=False) + + assert result.status == ("not_ready" if critical else "ready") + assert result.checks["temporal"].status == status + + @pytest.mark.asyncio + async def test_cancelled_critical_probe_cannot_report_ready(self): + probe = FakeProbe("temporal") + probe.check = AsyncMock(side_effect=asyncio.CancelledError()) + svc = HealthService(critical=[probe], informational=[]) + + assert (await svc.check_readiness(debug=False)).status == "not_ready" + @pytest.mark.asyncio async def test_all_up(self): svc = HealthService( diff --git a/backend/airweave/core/health/tests/test_startup.py b/backend/airweave/core/health/tests/test_startup.py new file mode 100644 index 0000000000..cdada5e756 --- /dev/null +++ b/backend/airweave/core/health/tests/test_startup.py @@ -0,0 +1,50 @@ +"""Critical Temporal startup failures must be recoverable through process restart.""" + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI + +from airweave import main +from airweave.core import container, metrics_service +from airweave.db import session + + +@pytest.mark.asyncio +@pytest.mark.parametrize("critical", [True, False]) +async def test_schedule_startup_failure_respects_critical_dependency(monkeypatch, critical): + """A required dependency cannot leave an API alive but permanently skipped.""" + services = MagicMock() + services.temporal_schedule_service.ensure_system_schedules = AsyncMock( + side_effect=[ConnectionError("Temporal unavailable"), None] + ) + monkeypatch.setattr(container, "container", services) + monkeypatch.setattr(container, "initialize_container", MagicMock()) + monkeypatch.setattr(main, "validate_embedding_config", AsyncMock()) + monkeypatch.setattr(main, "AsyncSessionLocal", MagicMock(return_value=AsyncMock())) + monkeypatch.setattr( + main.settings, + "HEALTH_CRITICAL_PROBES", + "postgres,temporal" if critical else "postgres", + ) + monkeypatch.setattr(session, "health_check_engine", AsyncMock()) + + @asynccontextmanager + async def metrics(*args): + yield + + monkeypatch.setattr(metrics_service, "metrics_lifespan", metrics) + + if critical: + with pytest.raises(ConnectionError, match="Temporal unavailable"): + async with main.lifespan(FastAPI()): + pytest.fail("Critical startup failure must not enter serving lifespan") + else: + async with main.lifespan(FastAPI()): + pass + + # A subsequent startup can succeed; no sticky health flag hides recovery. + async with main.lifespan(FastAPI()): + pass + assert services.temporal_schedule_service.ensure_system_schedules.await_count == 2 diff --git a/backend/airweave/core/logging.py b/backend/airweave/core/logging.py index 4387ed0826..03dbe9c94f 100644 --- a/backend/airweave/core/logging.py +++ b/backend/airweave/core/logging.py @@ -331,5 +331,11 @@ def configure_logger( return ContextualLogger(logger, prefix, dimensions) +# HTTPX INFO includes full URLs; HTTPCore DEBUG includes response headers/cookies. +# Application DEBUG must not implicitly enable those raw credential-bearing traces. +# Sanitized source diagnostics remain owned by AirweaveHttpClient. +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + # Default logger instance logger = LoggerConfigurator.configure_logger(__name__) diff --git a/backend/airweave/crud/crud_sync.py b/backend/airweave/crud/crud_sync.py index 2ab913372b..a75bcfc951 100644 --- a/backend/airweave/crud/crud_sync.py +++ b/backend/airweave/crud/crud_sync.py @@ -333,6 +333,7 @@ async def create( obj_in: SyncCreate, ctx: BaseContext, uow: Optional[UnitOfWork] = None, + initial_pipeline_version: int = 1, ) -> schemas.Sync: """Create a sync. @@ -347,11 +348,16 @@ async def create( obj_in (SyncCreate): The sync to create ctx (BaseContext): The API context uow (UnitOfWork, optional): The unit of work + initial_pipeline_version: Server-selected version for this new sync only. + Returns: schemas.Sync: The model validated schema of the created sync """ # Dump the obj_in to a dict + if initial_pipeline_version < 1: + raise ValueError("Initial projection version must be positive") obj_in_dict = obj_in.model_dump() + obj_in_dict["index_pipeline_version"] = initial_pipeline_version obj_in_dict.pop("run_immediately") # Pop off the connection ids diff --git a/backend/airweave/crud/crud_sync_cursor.py b/backend/airweave/crud/crud_sync_cursor.py index fd3f0fbba5..bdd5568eb8 100644 --- a/backend/airweave/crud/crud_sync_cursor.py +++ b/backend/airweave/crud/crud_sync_cursor.py @@ -9,6 +9,7 @@ from airweave import models, schemas from airweave.core.context import BaseContext from airweave.crud._base_organization import CRUDBaseOrganization +from airweave.domains.entities.canonical.cycle_models import CYCLE_KEY class CRUDSyncCursor( @@ -16,6 +17,21 @@ class CRUDSyncCursor( ): """CRUD operations for sync cursor.""" + async def _require_legacy_cursor( + self, db: AsyncSession, sync_id: UUID, ctx: BaseContext + ) -> None: + """Serialize with canonical writers before testing ownership, never erase active cycles.""" + sync = await db.scalar( + select(models.Sync) + .where(models.Sync.id == sync_id, models.Sync.organization_id == ctx.organization.id) + .with_for_update() + ) + if sync is None: + raise ValueError("Source does not exist in this organization") + cursor = await self.get_by_sync_id(db, sync_id=sync_id, ctx=ctx) + if cursor is not None and CYCLE_KEY in cursor.cursor_data: + raise ValueError("Cycle-owned progress cannot be changed by legacy cursor writes") + async def get_by_sync_id( self, db: AsyncSession, *, sync_id: UUID, ctx: BaseContext ) -> Optional[models.SyncCursor]: @@ -33,7 +49,7 @@ async def get_by_sync_id( models.SyncCursor.sync_id == sync_id, models.SyncCursor.organization_id == ctx.organization.id, ) - result = await db.execute(stmt) + result = await db.execute(stmt.execution_options(populate_existing=True)) return result.scalar_one_or_none() async def create_or_update( @@ -55,6 +71,9 @@ async def create_or_update( Returns: Created or updated sync cursor """ + await self._require_legacy_cursor(db, sync_id, ctx) + if CYCLE_KEY in obj_in.cursor_data: + raise ValueError("Reserved cycle state cannot be supplied by a source") # Check if cursor already exists for this sync existing_cursor = await self.get_by_sync_id(db, sync_id=sync_id, ctx=ctx) @@ -63,11 +82,7 @@ async def create_or_update( return await self.update(db, db_obj=existing_cursor, obj_in=obj_in, ctx=ctx) else: # Create new cursor - # Handle both dict and Pydantic model inputs - if isinstance(obj_in, dict): - obj_in["sync_id"] = sync_id - else: - obj_in.sync_id = sync_id + obj_in.sync_id = sync_id return await self.create(db, obj_in=obj_in, ctx=ctx) async def update_cursor_data( @@ -89,6 +104,9 @@ async def update_cursor_data( Returns: Updated sync cursor if found, None otherwise """ + await self._require_legacy_cursor(db, sync_id, ctx) + if CYCLE_KEY in cursor_data: + raise ValueError("Reserved cycle state cannot be supplied by a source") cursor = await self.get_by_sync_id(db, sync_id=sync_id, ctx=ctx) if cursor: @@ -108,6 +126,7 @@ async def delete_by_sync_id(self, db: AsyncSession, *, sync_id: UUID, ctx: BaseC Returns: True if deleted, False if not found """ + await self._require_legacy_cursor(db, sync_id, ctx) cursor = await self.get_by_sync_id(db, sync_id=sync_id, ctx=ctx) if cursor: diff --git a/backend/airweave/crud/crud_sync_job.py b/backend/airweave/crud/crud_sync_job.py index fa906b6820..6df91e7d0c 100644 --- a/backend/airweave/crud/crud_sync_job.py +++ b/backend/airweave/crud/crud_sync_job.py @@ -4,19 +4,57 @@ from typing import Optional from uuid import UUID +from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from airweave.core.context import BaseContext from airweave.crud._base_organization import CRUDBaseOrganization +from airweave.db.unit_of_work import UnitOfWork +from airweave.models.source_connection import SourceConnection from airweave.models.sync import Sync from airweave.models.sync_job import SyncJob from airweave.schemas.sync_job import SyncJobCreate, SyncJobUpdate +async def lock_sync_for_job(db: AsyncSession, organization: UUID, sync_id: UUID) -> Sync: + """Serialize admission with provisioning changes, including privileged admin runs.""" + sync = await db.scalar( + select(Sync) + .where(Sync.id == sync_id, Sync.organization_id == organization) + .with_for_update() + .execution_options(populate_existing=True) + ) + if sync is None: + raise HTTPException(status_code=404, detail="Sync not found") + if sync.provisioning_generation and ( + sync.provisioning_generation != sync.provisioning_ready_generation + or sync.status != "active" + ): + raise HTTPException(status_code=409, detail="Owned source is not ready for capture") + return sync + + class CRUDSyncJob(CRUDBaseOrganization[SyncJob, SyncJobCreate, SyncJobUpdate]): """CRUD operations for sync jobs.""" + async def create( + self, + db: AsyncSession, + *, + obj_in: SyncJobCreate, + ctx: BaseContext, + uow: Optional[UnitOfWork] = None, + skip_validation: bool = False, + ) -> SyncJob: + """Stamp job admission under the same lock used by account generation changes.""" + sync = await lock_sync_for_job(db, ctx.organization.id, obj_in.sync_id) + values = obj_in.model_dump(exclude_unset=True) + values["provisioning_generation"] = sync.provisioning_generation + return await super().create( + db, obj_in=values, ctx=ctx, uow=uow, skip_validation=skip_validation + ) + async def get(self, db: AsyncSession, id: UUID, ctx: BaseContext) -> SyncJob | None: """Get a sync job by ID.""" stmt = ( @@ -128,7 +166,18 @@ async def get_stuck_jobs_by_status( Returns: List of stuck sync jobs """ - stmt = select(SyncJob).where(SyncJob.status.in_(status)) + # Native imports share durable jobs, but own their explicit resume/cancel + # lifecycle. Provider heartbeat timeouts must never terminate them. + native_source = ( + select(SourceConnection.id) + .where( + SourceConnection.sync_id == SyncJob.sync_id, + SourceConnection.organization_id == SyncJob.organization_id, + SourceConnection.short_name == "almanac", + ) + .exists() + ) + stmt = select(SyncJob).where(SyncJob.status.in_(status), ~native_source) # Apply timestamp filters based on what's provided if modified_before is not None: diff --git a/backend/airweave/db/bootstrap.py b/backend/airweave/db/bootstrap.py new file mode 100644 index 0000000000..15909e426b --- /dev/null +++ b/backend/airweave/db/bootstrap.py @@ -0,0 +1,41 @@ +"""Explicit database setup: run after reviewed Alembic migrations, never on API startup. + +Usage: python -m airweave.db.bootstrap +Local demo only: AUTH_MODE=local python -m airweave.db.bootstrap --local-superuser +Service organization/key provisioning remains an explicit operator action. +""" + +import argparse +import asyncio + +from airweave.core.config import AuthMode, settings +from airweave.db.init_db import init_db +from airweave.db.init_db_native import init_db_with_native_connections +from airweave.db.session import AsyncSessionLocal +from airweave.domains.embedders.config import initialize_embedding_config + + +async def bootstrap(*, local_superuser: bool = False) -> None: + """Install native definitions, optionally opting into local demo identity.""" + if local_superuser and settings.AUTH_MODE != AuthMode.LOCAL: + raise ValueError("--local-superuser requires AUTH_MODE=local") + if local_superuser and not (settings.FIRST_SUPERUSER and settings.FIRST_SUPERUSER_PASSWORD): + raise ValueError("Local bootstrap requires FIRST_SUPERUSER and FIRST_SUPERUSER_PASSWORD") + async with AsyncSessionLocal() as db: + await initialize_embedding_config(db) + if local_superuser: + await init_db(db) + else: + await init_db_with_native_connections(db) + + +def main() -> None: + """Run the explicitly requested bootstrap operation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--local-superuser", action="store_true") + args = parser.parse_args() + asyncio.run(bootstrap(local_superuser=args.local_superuser)) + + +if __name__ == "__main__": + main() diff --git a/backend/airweave/domains/auth_provider/auth_result.py b/backend/airweave/domains/auth_provider/auth_result.py index d2b50b0ace..6424bbc85f 100644 --- a/backend/airweave/domains/auth_provider/auth_result.py +++ b/backend/airweave/domains/auth_provider/auth_result.py @@ -1,20 +1,29 @@ """Auth result types for auth provider credential fetch.""" +from __future__ import annotations + from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + ManagedToolAuthProvider, + ) @dataclass class AuthResult: """Result of auth provider credential fetch. - Credentials are always used directly by the source (no proxy mode). + Direct credentials and managed request access are distinct alternatives. source_config carries non-secret config fields (e.g., instance_url) that the auth provider extracted alongside credentials. """ credentials: Optional[Dict[str, Any]] = None source_config: Optional[Dict[str, Any]] = None + managed_auth: ManagedAuthProvider | ManagedToolAuthProvider | None = None @classmethod def direct(cls, credentials: Dict[str, Any]) -> "AuthResult": diff --git a/backend/airweave/domains/auth_provider/providers/composio.py b/backend/airweave/domains/auth_provider/providers/composio.py index 9d95daf5fe..9c672007a5 100644 --- a/backend/airweave/domains/auth_provider/providers/composio.py +++ b/backend/airweave/domains/auth_provider/providers/composio.py @@ -1,9 +1,11 @@ """Composio Test Auth Provider - provides authentication services for other integrations.""" -from typing import Any, Dict, List, Optional, Set +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set +from urllib.parse import quote from uuid import UUID import httpx +from pydantic import BaseModel, Field, StrictBool, ValidationError from airweave.core.credential_sanitizer import ( safe_log_credentials, @@ -12,6 +14,7 @@ from airweave.domains.auth_provider._base import BaseAuthProvider from airweave.domains.auth_provider.exceptions import ( AuthProviderAccountNotFoundError, + AuthProviderConfigError, AuthProviderMissingFieldsError, AuthProviderRateLimitError, AuthProviderTemporaryError, @@ -20,6 +23,32 @@ from airweave.platform.configs.config import ComposioConfig from airweave.platform.decorators import auth_provider +if TYPE_CHECKING: + from airweave.domains.auth_provider.auth_result import AuthResult + + +class _AuthConfigAvailability(BaseModel): + """Read the optional broker flag; unrelated provider fields remain opaque.""" + + is_disabled: StrictBool = False + + +class _AccountAvailability(BaseModel): + """Missing flags preserve compatibility; present flags must be actual booleans.""" + + is_disabled: StrictBool = False + auth_config: _AuthConfigAvailability = Field(default_factory=_AuthConfigAvailability) + + @classmethod + def from_response(cls, account: dict) -> "_AccountAvailability": + """Reject malformed flags without exposing provider data in validation errors.""" + try: + return cls.model_validate(account) + except ValidationError: + raise AuthProviderConfigError( + "Connected account availability is invalid", provider_name="composio" + ) from None + @auth_provider( name="Composio", @@ -53,6 +82,7 @@ class ComposioAuthProvider(BaseAuthProvider): # Key: Airweave short name, Value: Composio toolkit slug # Only include mappings where names differ between Airweave and Composio SLUG_NAME_MAPPING = { + "wispr": "wispr_flow_mcp", "google_drive": "googledrive", "google_calendar": "googlecalendar", "google_docs": "googledocs", @@ -86,9 +116,102 @@ async def create( instance.api_key = credentials["api_key"] instance.auth_config_id = config.get("auth_config_id") instance.account_id = config.get("account_id") + instance.user_id = config.get("user_id") instance._last_credential_blob = None return instance + async def get_auth_result( + self, + source_short_name: str, + source_auth_config_fields: List[str], + optional_fields: Optional[Set[str]] = None, + source_config_field_mappings: Optional[Dict[str, str]] = None, + source_connection_id: Optional[UUID] = None, + ) -> "AuthResult": + """Bind managed access without requesting or extracting OAuth tokens.""" + from airweave.domains.auth_provider.auth_result import AuthResult + from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + ManagedToolAuthProvider, + ) + + hosts = { + "gmail": frozenset({"gmail.googleapis.com"}), + "outlook_mail": frozenset({"graph.microsoft.com"}), + "outlook_calendar": frozenset({"graph.microsoft.com"}), + "google_calendar": frozenset({"www.googleapis.com"}), + "google_drive": frozenset( + {"www.googleapis.com", "docs.googleapis.com", "sheets.googleapis.com"} + ), + "slack": frozenset({"slack.com", "files.slack.com"}), + "linear": frozenset({"api.linear.app", "uploads.linear.app"}), + "github": frozenset({"api.github.com"}), + "notion": frozenset({"api.notion.com"}), + "attio": frozenset({"api.attio.com"}), + "stripe": frozenset({"api.stripe.com"}), + } + if source_short_name not in hosts and source_short_name != "wispr": + raise AuthProviderConfigError( + "Managed Composio requests are not supported for this source yet", + provider_name="composio", + ) + if not self.account_id: + raise AuthProviderConfigError( + "An explicit Composio account is required", provider_name="composio" + ) + async with httpx.AsyncClient() as client: + account = await self._get_with_auth( + client, + "https://backend.composio.dev/api/v3/connected_accounts/" + + quote(self.account_id, safe=""), + ) + availability = _AccountAvailability.from_response(account) + if account.get("id") != self.account_id: + raise AuthProviderConfigError( + "Connected account identity does not match", provider_name="composio" + ) + if self.user_id is not None and account.get("user_id") != self.user_id: + raise AuthProviderConfigError( + "Connected account user does not match", provider_name="composio" + ) + if account.get("toolkit", {}).get("slug", "").lower() != self._get_composio_slug( + source_short_name + ): + raise AuthProviderConfigError( + "Connected account toolkit does not match source", provider_name="composio" + ) + if self.auth_config_id and account.get("auth_config", {}).get("id") != self.auth_config_id: + raise AuthProviderConfigError( + "Connected account auth config does not match", provider_name="composio" + ) + if ( + account.get("status", "").upper() != "ACTIVE" + or availability.is_disabled + or availability.auth_config.is_disabled + ): + from airweave.domains.auth_provider.exceptions import AuthProviderAuthError + + raise AuthProviderAuthError( + "Connected account requires attention", provider_name="composio" + ) + if source_short_name == "wispr": + if not self.user_id: + raise AuthProviderConfigError( + "Wispr requires an explicit Composio user binding", provider_name="composio" + ) + return AuthResult( + managed_auth=ManagedToolAuthProvider( + api_key=self.api_key, connected_account_id=self.account_id, user_id=self.user_id + ) + ) + return AuthResult( + managed_auth=ManagedAuthProvider( + api_key=self.api_key, + connected_account_id=self.account_id, + allowed_hosts=hosts[source_short_name], + ) + ) + def _get_composio_slug(self, airweave_short_name: str) -> str: """Get the Composio toolkit slug for an Airweave source short name. @@ -143,13 +266,20 @@ async def _get_with_auth( except httpx.HTTPStatusError as e: status = e.response.status_code self.logger.error(f"HTTP error from Composio API: {status} for {url}") - if status == 401: + if status in (401, 403): raise AuthProviderAuthError( "Composio API key is invalid or revoked", provider_name="composio", ) from e + if status == 404: + raise AuthProviderAccountNotFoundError( + "Composio connected account was not found", provider_name="composio" + ) from None if status == 429: - retry_after = float(e.response.headers.get("retry-after", 30)) + try: + retry_after = float(e.response.headers.get("retry-after", 30)) + except ValueError: + retry_after = 30.0 raise AuthProviderRateLimitError( "Composio API rate-limited", provider_name="composio", @@ -220,6 +350,7 @@ async def get_creds_for_source( source_short_name: The short name of the source to get credentials for source_auth_config_fields: The fields required for the source auth config optional_fields: Fields that can be skipped if not available in Composio + source_connection_id: Source connection requesting credentials. Returns: Credentials dictionary for the source diff --git a/backend/airweave/domains/auth_provider/tests/test_composio_binding.py b/backend/airweave/domains/auth_provider/tests/test_composio_binding.py new file mode 100644 index 0000000000..18251b08cb --- /dev/null +++ b/backend/airweave/domains/auth_provider/tests/test_composio_binding.py @@ -0,0 +1,155 @@ +"""Managed access requires the selected account and any explicit user binding.""" + +from unittest.mock import AsyncMock + +import pytest + +from airweave.domains.auth_provider.exceptions import ( + AuthProviderAuthError, + AuthProviderConfigError, +) +from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source,toolkit", + [ + ("gmail", "gmail"), + ("google_calendar", "googlecalendar"), + ("google_drive", "googledrive"), + ("slack", "slack"), + ("linear", "linear"), + ("github", "github"), + ("notion", "notion"), + ("wispr", "wispr_flow_mcp"), + ], +) +@pytest.mark.parametrize( + "field,value", + [ + ("id", "wrong-account"), + ("id", None), + ("user_id", "wrong-user"), + ("user_id", None), + ], +) +async def test_managed_binding_rejects_wrong_or_missing_identity( + monkeypatch, + source, + toolkit, + field, + value, +): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, + config={"account_id": "selected", "user_id": "owner"}, + ) + account = { + "id": "selected", + "user_id": "owner", + "toolkit": {"slug": toolkit}, + "status": "ACTIVE", + } + if value is None: + account.pop(field) + else: + account[field] = value + monkeypatch.setattr(provider, "_get_with_auth", AsyncMock(return_value=account)) + with pytest.raises(AuthProviderConfigError, match="identity" if field == "id" else "user"): + await provider.get_auth_result(source, []) + assert provider._last_credential_blob is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source,user_id", + [ + ("stripe", "owner"), + ("notion", None), + ("notion", "owner"), + ("wispr", "owner"), + ], +) +async def test_managed_binding_accepts_exact_identity_without_requiring_optional_user( + monkeypatch, + source, + user_id, +): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, + config={"account_id": "selected", "user_id": user_id}, + ) + account = { + "id": "selected", + "user_id": "owner", + "toolkit": {"slug": "wispr_flow_mcp" if source == "wispr" else source}, + "status": "ACTIVE", + } + monkeypatch.setattr(provider, "_get_with_auth", AsyncMock(return_value=account)) + result = await provider.get_auth_result(source, []) + assert result.credentials is None + assert result.managed_auth.connected_account_id == "selected" + + +@pytest.mark.asyncio +async def test_wispr_still_requires_configured_user(monkeypatch): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, + config={"account_id": "selected"}, + ) + monkeypatch.setattr( + provider, + "_get_with_auth", + AsyncMock( + return_value={ + "id": "selected", + "user_id": "owner", + "toolkit": {"slug": "wispr_flow_mcp"}, + "status": "ACTIVE", + } + ), + ) + with pytest.raises(AuthProviderConfigError, match="explicit Composio user"): + await provider.get_auth_result("wispr", []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "account_disabled,config_disabled,error", + [ + (False, False, None), + (True, False, AuthProviderAuthError), + (False, True, AuthProviderAuthError), + ("false", False, AuthProviderConfigError), + (False, 0, AuthProviderConfigError), + (None, False, AuthProviderConfigError), + ], +) +async def test_managed_binding_validates_disabled_flags( + monkeypatch, account_disabled, config_disabled, error +): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, + config={"account_id": "selected", "user_id": "owner", "auth_config_id": "cfg"}, + ) + monkeypatch.setattr( + provider, + "_get_with_auth", + AsyncMock( + return_value={ + "id": "selected", + "user_id": "owner", + "toolkit": {"slug": "wispr_flow_mcp"}, + "status": "ACTIVE", + "is_disabled": account_disabled, + "auth_config": {"id": "cfg", "is_disabled": config_disabled}, + } + ), + ) + if error is None: + result = await provider.get_auth_result("wispr", []) + assert result.managed_auth.connected_account_id == "selected" + else: + with pytest.raises(error): + await provider.get_auth_result("wispr", []) diff --git a/backend/airweave/domains/embedders/config.py b/backend/airweave/domains/embedders/config.py index f5170a3221..94849fcda0 100644 --- a/backend/airweave/domains/embedders/config.py +++ b/backend/airweave/domains/embedders/config.py @@ -107,6 +107,13 @@ async def validate_embedding_config(db: AsyncSession) -> None: await _reconcile_db(db) +async def initialize_embedding_config(db: AsyncSession) -> None: + """Persist first-deployment metadata only from explicit database bootstrap.""" + if not DENSE_EMBEDDER or not EMBEDDING_DIMENSIONS or not SPARSE_EMBEDDER: + raise EmbeddingConfigError("Set dense/sparse embedder and dimensions before bootstrap") + await _reconcile_db(db, initialize=True) + + def _validate_dimensions(dense_spec: DenseEmbedderEntry) -> None: """Validate EMBEDDING_DIMENSIONS against the dense embedder spec.""" if dense_spec.supports_matryoshka: @@ -158,7 +165,7 @@ def _validate_local_reachability(dense_spec: DenseEmbedderEntry) -> None: return inference_url = settings.TEXT2VEC_INFERENCE_URL - health_url = f"{inference_url}/health" + health_url = f"{inference_url}/.well-known/ready" try: with httpx.Client(timeout=httpx.Timeout(5.0)) as client: @@ -175,12 +182,17 @@ def _validate_local_reachability(dense_spec: DenseEmbedderEntry) -> None: ) from exc -async def _reconcile_db(db: AsyncSession) -> None: +async def _reconcile_db(db: AsyncSession, *, initialize: bool = False) -> None: """Reconcile code config against the vector_db_deployment_metadata table.""" result = await db.execute(select(VectorDbDeploymentMetadata).limit(1)) row = result.scalar_one_or_none() if row is None: + if not initialize: + raise EmbeddingConfigError( + "Embedding metadata is missing; run python -m airweave.db.bootstrap " + "against the intended database before starting the service." + ) row = VectorDbDeploymentMetadata( dense_embedder=DENSE_EMBEDDER, embedding_dimensions=EMBEDDING_DIMENSIONS, diff --git a/backend/airweave/domains/embedders/dense/cohere.py b/backend/airweave/domains/embedders/dense/cohere.py new file mode 100644 index 0000000000..9c16da6898 --- /dev/null +++ b/backend/airweave/domains/embedders/dense/cohere.py @@ -0,0 +1,107 @@ +"""Cohere text embeddings with explicit retrieval intent and bounded requests.""" + +import math + +import cohere +import httpx +from cohere.core.api_error import ApiError +from cohere.types import EmbedByTypeResponse + +from airweave.domains.embedders.exceptions import ( + EmbedderAuthError, + EmbedderConfigError, + EmbedderConnectionError, + EmbedderDimensionError, + EmbedderInputError, + EmbedderProviderError, + EmbedderRateLimitError, + EmbedderResponseError, + EmbedderTimeoutError, +) +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose + + +class CohereDenseEmbedder: + """Use the existing SDK; callers own retries and model migration policy.""" + + def __init__(self, *, api_key: str, model: str, dimensions: int) -> None: + """Fail configuration before creating network resources.""" + allowed = { + "embed-v4.0": {256, 512, 1024, 1536}, + "embed-v5.0-pro": {256, 512, 768, 1024, 1536, 2048}, + "embed-v5.0-fast": {256, 512, 768, 1024, 1536, 2048}, + } + if not api_key or model not in allowed or dimensions not in allowed[model]: + raise EmbedderConfigError("Cohere requires an API key, supported model and dimension") + self._model = model + self._dimensions = dimensions + self._http = httpx.AsyncClient(timeout=60) + self._client = cohere.AsyncClientV2(api_key=api_key, httpx_client=self._http) + + @property + def model_name(self) -> str: + """Persist this identity alongside vector dimensions.""" + return self._model + + @property + def dimensions(self) -> int: + """Configured output width.""" + return self._dimensions + + async def embed(self, text: str) -> DenseEmbedding: + """Embed a document; query callers must use the explicit batch purpose.""" + return (await self.embed_many([text]))[0] + + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: + """Keep order, reject silent truncation, and avoid unbounded request fan-out.""" + if purpose not in ("document", "query") or any(not text.strip() for text in texts): + raise EmbedderInputError("Cohere requires nonblank text and a valid embedding purpose") + results = [] + for offset in range(0, len(texts), 96): + batch = texts[offset : offset + 96] + response = await self._request(batch, purpose) + vectors = response.embeddings.float_ + if vectors is None or len(vectors) != len(batch): + raise EmbedderResponseError("Cohere returned an unexpected vector count") + for vector in vectors: + if len(vector) != self._dimensions: + raise EmbedderDimensionError(expected=self._dimensions, actual=len(vector)) + if not all(math.isfinite(value) for value in vector): + raise EmbedderResponseError("Cohere returned a non-finite vector") + results.append(DenseEmbedding(vector=vector)) + return results + + async def _request( + self, batch: list[str], purpose: EmbeddingPurpose + ) -> EmbedByTypeResponse: + """Translate provider errors without exposing request text or credentials.""" + try: + return await self._client.embed( + model=self._model, + texts=batch, + input_type="search_query" if purpose == "query" else "search_document", + output_dimension=self._dimensions, + embedding_types=["float"], + truncate="NONE", + request_options={"max_retries": 0}, + ) + except ApiError as exc: + if exc.status_code in (401, 403): + raise EmbedderAuthError("Cohere authentication failed", provider="cohere") from exc + if exc.status_code == 429: + raise EmbedderRateLimitError("Cohere rate limited", provider="cohere") from exc + raise EmbedderProviderError( + "Cohere embedding request failed", + provider="cohere", + retryable=exc.status_code is not None and exc.status_code >= 500, + ) from exc + except httpx.TimeoutException as exc: + raise EmbedderTimeoutError(provider="cohere") from exc + except httpx.RequestError as exc: + raise EmbedderConnectionError(provider="cohere") from exc + + async def close(self) -> None: + """Release the transport owned by this adapter.""" + await self._http.aclose() diff --git a/backend/airweave/domains/embedders/dense/local.py b/backend/airweave/domains/embedders/dense/local.py index 2acd61ee02..d66b96bb87 100644 --- a/backend/airweave/domains/embedders/dense/local.py +++ b/backend/airweave/domains/embedders/dense/local.py @@ -18,7 +18,7 @@ EmbedderResponseError, EmbedderTimeoutError, ) -from airweave.domains.embedders.types import DenseEmbedding +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose _PROVIDER = "local" @@ -75,7 +75,9 @@ async def embed(self, text: str) -> DenseEmbedding: results = await self.embed_many([text]) return results[0] - async def embed_many(self, texts: list[str]) -> list[DenseEmbedding]: + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: """Embed a batch of texts into dense vectors.""" if not texts: return [] diff --git a/backend/airweave/domains/embedders/dense/mistral.py b/backend/airweave/domains/embedders/dense/mistral.py index 8bde0a785d..76ade773ac 100644 --- a/backend/airweave/domains/embedders/dense/mistral.py +++ b/backend/airweave/domains/embedders/dense/mistral.py @@ -21,7 +21,7 @@ EmbedderTimeoutError, ) from airweave.domains.embedders.protocols import DenseEmbedderProtocol -from airweave.domains.embedders.types import DenseEmbedding +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose from airweave.platform.tokenizers import get_tokenizer _PROVIDER = "mistral" @@ -85,7 +85,9 @@ async def embed(self, text: str) -> DenseEmbedding: results = await self.embed_many([text]) return results[0] - async def embed_many(self, texts: list[str]) -> list[DenseEmbedding]: + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: """Embed a batch of texts into dense vectors.""" if not texts: return [] diff --git a/backend/airweave/domains/embedders/dense/openai.py b/backend/airweave/domains/embedders/dense/openai.py index 0fd574fe7c..355f2c0476 100644 --- a/backend/airweave/domains/embedders/dense/openai.py +++ b/backend/airweave/domains/embedders/dense/openai.py @@ -21,7 +21,7 @@ EmbedderTimeoutError, ) from airweave.domains.embedders.protocols import DenseEmbedderProtocol -from airweave.domains.embedders.types import DenseEmbedding +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose _PROVIDER = "openai" @@ -84,7 +84,9 @@ async def embed(self, text: str) -> DenseEmbedding: results = await self.embed_many([text]) return results[0] - async def embed_many(self, texts: list[str]) -> list[DenseEmbedding]: + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: """Embed a batch of texts into dense vectors.""" if not texts: return [] diff --git a/backend/airweave/domains/embedders/dense/tests/test_cohere.py b/backend/airweave/domains/embedders/dense/tests/test_cohere.py new file mode 100644 index 0000000000..a40283cd79 --- /dev/null +++ b/backend/airweave/domains/embedders/dense/tests/test_cohere.py @@ -0,0 +1,99 @@ +"""Cohere boundary: real SDK serialization over a mocked HTTP transport.""" + +import httpx +import pytest + +from airweave.domains.embedders.dense.cohere import CohereDenseEmbedder +from airweave.domains.embedders.exceptions import ( + EmbedderAuthError, + EmbedderDimensionError, + EmbedderRateLimitError, + EmbedderResponseError, +) + + +@pytest.fixture +def build(monkeypatch): + original = httpx.AsyncClient + + def factory(handler): + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs), + ) + return CohereDenseEmbedder(api_key="fixture", model="embed-v5.0-pro", dimensions=256) + + return factory + + +def response(vectors): + return httpx.Response( + 200, + json={ + "id": "test", + "texts": [], + "embeddings": {"float": vectors}, + "meta": {"api_version": {"version": "2"}}, + }, + ) + + +async def test_intent_batch_order_and_no_truncation(build): + import json + + calls = [] + + def handler(request): + body = json.loads(request.content) + calls.append(body) + return response([[float(text.split(":")[0])] * 256 for text in body["texts"]]) + + embedder = build(handler) + try: + texts = [f"{i}:नमस्ते hello" for i in range(97)] + vectors = await embedder.embed_many(texts) + await embedder.embed_many(["0:नमस्ते"], purpose="query") + assert [v.vector[0] for v in vectors] == list(range(97)) + assert [len(call["texts"]) for call in calls] == [96, 1, 1] + assert [call["input_type"] for call in calls] == [ + "search_document", + "search_document", + "search_query", + ] + assert all(call["truncate"] == "NONE" for call in calls) + assert calls[0]["texts"][0] == texts[0] + assert await embedder.embed_many([]) == [] + assert len(calls) == 3 + finally: + await embedder.close() + + +@pytest.mark.parametrize("status,error", [(401, EmbedderAuthError), (429, EmbedderRateLimitError)]) +async def test_safe_errors_and_no_hidden_retries(build, status, error): + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response(status, json={"message": "private provider detail"}) + + embedder = build(handler) + try: + with pytest.raises(error) as exc: + await embedder.embed("private document") + assert "private" not in str(exc.value) + assert len(calls) == 1 + finally: + await embedder.close() + + +@pytest.mark.parametrize( + "vectors,error", [([], EmbedderResponseError), ([[1.0]], EmbedderDimensionError)] +) +async def test_reject_corrupt_response(build, vectors, error): + embedder = build(lambda request: response(vectors)) + try: + with pytest.raises(error): + await embedder.embed("content") + finally: + await embedder.close() diff --git a/backend/airweave/domains/embedders/fakes/embedder.py b/backend/airweave/domains/embedders/fakes/embedder.py index 20e72b743d..2599f91373 100644 --- a/backend/airweave/domains/embedders/fakes/embedder.py +++ b/backend/airweave/domains/embedders/fakes/embedder.py @@ -1,6 +1,6 @@ """Fake embedder implementations for testing.""" -from airweave.domains.embedders.types import DenseEmbedding, SparseEmbedding +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose, SparseEmbedding class FakeDenseEmbedder: @@ -44,7 +44,9 @@ async def embed(self, text: str) -> DenseEmbedding: self._check_error() return DenseEmbedding(vector=[0.0] * self._dimensions) - async def embed_many(self, texts: list[str]) -> list[DenseEmbedding]: + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: """Return zero-vectors for each text.""" self._check_error() return [DenseEmbedding(vector=[0.0] * self._dimensions) for _ in texts] diff --git a/backend/airweave/domains/embedders/protocols.py b/backend/airweave/domains/embedders/protocols.py index 7a71aa3db6..becb303b2b 100644 --- a/backend/airweave/domains/embedders/protocols.py +++ b/backend/airweave/domains/embedders/protocols.py @@ -6,6 +6,7 @@ from airweave.domains.embedders.types import ( DenseEmbedderEntry, DenseEmbedding, + EmbeddingPurpose, SparseEmbedderEntry, SparseEmbedding, ) @@ -32,8 +33,10 @@ async def embed(self, text: str) -> DenseEmbedding: """Embed a single text into a dense vector.""" ... - async def embed_many(self, texts: list[str]) -> list[DenseEmbedding]: - """Embed a batch of texts into dense vectors.""" + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: + """Embed ordered texts; asymmetric models distinguish queries from documents.""" ... async def close(self) -> None: diff --git a/backend/airweave/domains/embedders/registry_data.py b/backend/airweave/domains/embedders/registry_data.py index 5ca56de30e..cbcb5a0d61 100644 --- a/backend/airweave/domains/embedders/registry_data.py +++ b/backend/airweave/domains/embedders/registry_data.py @@ -6,6 +6,7 @@ from dataclasses import dataclass +from airweave.domains.embedders.dense.cohere import CohereDenseEmbedder from airweave.domains.embedders.dense.local import LocalDenseEmbedder from airweave.domains.embedders.dense.mistral import MistralDenseEmbedder from airweave.domains.embedders.dense.openai import OpenAIDenseEmbedder @@ -92,6 +93,27 @@ class SparseEmbedderSpec: ), ] +# Text-only registrations; image processing requires a separate qualified input path. +DENSE_EMBEDDERS.extend( + DenseEmbedderSpec( + short_name=short_name, + name=f"Cohere {model}", + description="Multilingual embeddings with explicit query/document purpose", + provider="cohere", + api_model_name=model, + max_dimensions=dimensions, + max_tokens=128000, + supports_matryoshka=True, + embedder_class=CohereDenseEmbedder, + required_setting="COHERE_API_KEY", + ) + for short_name, model, dimensions in ( + ("cohere_embed_v4", "embed-v4.0", 1536), + ("cohere_embed_v5_pro", "embed-v5.0-pro", 2048), + ("cohere_embed_v5_fast", "embed-v5.0-fast", 2048), + ) +) + SPARSE_EMBEDDERS: list[SparseEmbedderSpec] = [ SparseEmbedderSpec( short_name="fastembed_bm25", diff --git a/backend/airweave/domains/embedders/tests/test_local_reachability.py b/backend/airweave/domains/embedders/tests/test_local_reachability.py index 4be2eb8c44..36d02e0599 100644 --- a/backend/airweave/domains/embedders/tests/test_local_reachability.py +++ b/backend/airweave/domains/embedders/tests/test_local_reachability.py @@ -16,19 +16,19 @@ def _make_entry(embedder_class: type, **overrides) -> DenseEmbedderEntry: """Build a minimal DenseEmbedderEntry for testing.""" - defaults = dict( - short_name="test", - name="Test", - description="test entry", - class_name=embedder_class.__name__, - provider="local", - api_model_name="test-model", - max_dimensions=384, - max_tokens=512, - supports_matryoshka=False, - embedder_class_ref=embedder_class, - required_setting="TEXT2VEC_INFERENCE_URL", - ) + defaults = { + "short_name": "test", + "name": "Test", + "description": "test entry", + "class_name": embedder_class.__name__, + "provider": "local", + "api_model_name": "test-model", + "max_dimensions": 384, + "max_tokens": 512, + "supports_matryoshka": False, + "embedder_class_ref": embedder_class, + "required_setting": "TEXT2VEC_INFERENCE_URL", + } defaults.update(overrides) return DenseEmbedderEntry(**defaults) @@ -62,7 +62,7 @@ def test_skips_non_local_embedder(self): @patch("airweave.domains.embedders.config.settings") @patch("airweave.domains.embedders.config.httpx") def test_passes_when_service_reachable(self, mock_httpx, mock_settings): - """Passes when the health endpoint returns 200.""" + """Passes when the readiness endpoint responds successfully.""" mock_settings.TEXT2VEC_INFERENCE_URL = "http://localhost:9878" mock_httpx.Timeout = httpx.Timeout client = _mock_client() @@ -71,7 +71,7 @@ def test_passes_when_service_reachable(self, mock_httpx, mock_settings): entry = _make_entry(LocalDenseEmbedder) _validate_local_reachability(entry) - client.get.assert_called_once_with("http://localhost:9878/health") + client.get.assert_called_once_with("http://localhost:9878/.well-known/ready") @patch("airweave.domains.embedders.config.settings") @patch("airweave.domains.embedders.config.httpx") diff --git a/backend/airweave/domains/embedders/types.py b/backend/airweave/domains/embedders/types.py index bd16b262f8..5ec49c877b 100644 --- a/backend/airweave/domains/embedders/types.py +++ b/backend/airweave/domains/embedders/types.py @@ -1,9 +1,13 @@ """Types for the embedders domain.""" +from typing import Literal + from pydantic import BaseModel, Field from airweave.core.protocols.registry import BaseRegistryEntry +EmbeddingPurpose = Literal["document", "query"] + # --------------------------------------------------------------------------- # Embedding value types # --------------------------------------------------------------------------- diff --git a/backend/airweave/domains/entities/canonical/__init__.py b/backend/airweave/domains/entities/canonical/__init__.py new file mode 100644 index 0000000000..9d725c01ad --- /dev/null +++ b/backend/airweave/domains/entities/canonical/__init__.py @@ -0,0 +1 @@ +"""Canonical source records and their committed observed changes.""" diff --git a/backend/airweave/domains/entities/canonical/attio_projection.py b/backend/airweave/domains/entities/canonical/attio_projection.py new file mode 100644 index 0000000000..eafb9f9843 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/attio_projection.py @@ -0,0 +1,162 @@ +"""Offline search views of Attio originals; membership never owns CRM record content.""" + +import json +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, HttpUrl, JsonValue + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import ProjectionMappingError +from airweave.platform.entities.attio import AttioOriginalEntity + + +class _Native(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class _Identity(_Native): + workspace_id: UUID + object_id: UUID | None = None + record_id: UUID | None = None + list_id: UUID | None = None + entry_id: UUID | None = None + note_id: UUID | None = None + + +class _Original(_Native): + id: _Identity + + +class _Object(_Original): + singular_noun: str + plural_noun: str + api_slug: str + + +class _List(_Original): + name: str + + +class _Record(_Original): + web_url: HttpUrl | None = None + values: dict[str, JsonValue] + + +class _Entry(_Original): + parent_record_id: UUID + parent_object: str + entry_values: dict[str, JsonValue] + + +class _Note(_Original): + title: str + parent_record_id: UUID + parent_object: str + content_plaintext: str + content_markdown: str + + +class _Name(_Native): + full_name: str | None = None + value: str | None = None + + +def _attributes(values: dict[str, JsonValue]) -> str: + # Arbitrary custom attributes are data, not guessed standard properties. Preserve + # their names, values and references without fetching or joining other originals. + return json.dumps(values, ensure_ascii=False, sort_keys=True, indent=2) + + +def _parent(record: SourceRecord, kind: str, native_id: UUID | None) -> None: + if ( + native_id is None + or record.identity.container_id != str(native_id) + or record.parent is None + or record.parent.record_type != kind + or record.parent.native_id != str(native_id) + or (kind in {"object", "list"} and record.parent.container_id is not None) + ): + raise ProjectionMappingError("Attio parent differs from captured identity") + + +def _url(url: HttpUrl | None) -> str | None: + if url is None: + return None + if ( + url.scheme != "https" + or url.host != "app.attio.com" + or url.port not in (None, 443) + or url.username + or url.password + ): + raise ProjectionMappingError("Attio URL is outside the native application host") + return str(url) + + +def _record_title(original: _Record, native_id: UUID) -> str: + title = f"Record {native_id}" + names = original.values.get("name") + if isinstance(names, list) and names: + name = _Name.model_validate(names[0]) + title = name.full_name or name.value or title + return title + + +def map_attio(record: SourceRecord) -> tuple[AttioOriginalEntity, ...]: + """Render each native identity separately, without inventing URLs or related content.""" + native = _Original.model_validate(record.payload) + kind = record.identity.record_type + native_url = None + ids = native.id + native_id = { + "object": ids.object_id, + "record": ids.record_id, + "list": ids.list_id, + "entry": ids.entry_id, + "note": ids.note_id, + }.get(kind) + if native_id is None or str(native_id) != record.identity.native_id: + raise ProjectionMappingError("Attio native identity differs from captured identity") + if kind in {"object", "list"}: + if record.parent is not None or record.identity.container_id is not None: + raise ProjectionMappingError("Attio root has an unexpected capture parent") + if kind == "object": + obj = _Object.model_validate(record.payload) + title, text = obj.plural_noun, f"{obj.singular_noun}\n{obj.api_slug}" + else: + title, text = _List.model_validate(record.payload).name, "" + coverage = "Container metadata only; records and memberships are separate originals." + elif kind == "record": + _parent(record, "object", ids.object_id) + original = _Record.model_validate(record.payload) + native_url = _url(original.web_url) + title = _record_title(original, native_id) + text = _attributes(original.values) + coverage = "Retained attributes only; notes, memberships and file contents are separate." + elif kind == "entry": + _parent(record, "list", ids.list_id) + entry = _Entry.model_validate(record.payload) + title = f"List entry {native_id}" + text = f"Referenced {entry.parent_object} record: {entry.parent_record_id}\n" + text += _attributes(entry.entry_values) + coverage = "List membership and its attributes only; referenced CRM record is separate." + elif kind == "note": + note = _Note.model_validate(record.payload) + _parent(record, "record", note.parent_record_id) + title, text = note.title, note.content_plaintext + coverage = "Retained note text only; embedded media bytes are not included." + else: + raise ProjectionMappingError("Unsupported Attio original kind") + return ( + AttioOriginalEntity( + native_id=str(native_id), + original_kind=kind, + title=title or kind.capitalize(), + text=text, + content_coverage=coverage, + native_url=native_url, + breadcrumbs=[], + created_at=record.source_created_at, + updated_at=record.source_updated_at, + ), + ) diff --git a/backend/airweave/domains/entities/canonical/blob_materializer.py b/backend/airweave/domains/entities/canonical/blob_materializer.py new file mode 100644 index 0000000000..cefb1bc956 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/blob_materializer.py @@ -0,0 +1,47 @@ +"""Verify canonical blob identity before materializing disposable projection inputs.""" + +import hashlib +import re +from pathlib import Path + +import aiofiles + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import BlobReference +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES +from airweave.domains.storage.protocols import StorageBackend + + +class BlobIntegrityError(ValueError): + """Committed reference or bytes violate canonical ownership/integrity.""" + + +async def read_blob(record: SourceRecord, ref: BlobReference, storage: StorageBackend) -> bytes: + """Read only this sync's content-addressed bytes; reject corrupt references/content.""" + expected = f"canonical/{record.sync_id}/blobs/sha256/{ref.sha256}" + if ref.key != expected: + raise BlobIntegrityError("Canonical blob key does not match source scope and digest") + if ref not in record.blobs: + raise BlobIntegrityError("Canonical blob reference does not belong to the source record") + if ref.size_bytes > MAX_FILE_SIZE_BYTES: + raise BlobIntegrityError("Canonical blob exceeds projection size limit") + content = await storage.read_file(ref.key, max_bytes=ref.size_bytes) + if len(content) != ref.size_bytes or hashlib.sha256(content).hexdigest() != ref.sha256: + raise BlobIntegrityError("Canonical blob bytes do not match recorded size and digest") + return content + + +async def write_blob(content: bytes, directory: Path, *, suffix: str) -> Path: + """Use trusted extension and content hash, never provider filenames, for local paths.""" + if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix): + raise ValueError("Projection filename suffix must be one simple extension") + if len(content) > MAX_FILE_SIZE_BYTES: + raise ValueError("Projection content exceeds size limit") + directory.mkdir(parents=True, exist_ok=True) + path = directory / (hashlib.sha256(content).hexdigest() + suffix) + # A disposable per-projection directory is owned by the caller, not provider data. + if path.is_symlink(): + raise ValueError("Projection path must not be a symbolic link") + async with aiofiles.open(path, "wb") as output: + await output.write(content) + return path diff --git a/backend/airweave/domains/entities/canonical/calendar.py b/backend/airweave/domains/entities/canonical/calendar.py new file mode 100644 index 0000000000..849b7e9928 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/calendar.py @@ -0,0 +1,29 @@ +"""Google Calendar recurrence exclusions are scheduling state, not live meetings.""" + +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict + + +def is_cancelled_recurring_event(payload: Mapping[str, object]) -> bool: + """Recognize the provider-guaranteed identity of a cancelled series occurrence.""" + if payload.get("status") != "cancelled": + return False + recurring_id = payload.get("recurringEventId") + original = payload.get("originalStartTime") + return ( + isinstance(recurring_id, str) + and bool(recurring_id) + and isinstance(original, dict) + and any( + isinstance(original.get(key), str) and original[key] for key in ("date", "dateTime") + ) + ) + + +class CalendarScopeContext(BaseModel): + """Effective native parameters and timezone used by the same range publication.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + parameters: dict[str, str | int] + timezone: str | None = None diff --git a/backend/airweave/domains/entities/canonical/calendar_exact.py b/backend/airweave/domains/entities/canonical/calendar_exact.py new file mode 100644 index 0000000000..2575d17c05 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/calendar_exact.py @@ -0,0 +1,157 @@ +"""Bounded Google event identity resolution over already captured observations.""" + +from datetime import date as Date +from typing import Literal, Self +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError, model_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.calendar import is_cancelled_recurring_event +from airweave.domains.entities.canonical.calendar_query import CalendarChanged +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.query import RecordNotFound +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import ( + CanonicalStoreError, + content_is_available, + parent_is_visible, + source_record, +) +from airweave.models.entity import Entity +from airweave.models.sync import Sync + + +class CalendarReadIncomplete(CanonicalStoreError): + """Existing observations cannot safely establish one scheduling state.""" + + code = "calendar_read_incomplete" + + +class Version(BaseModel): + """Only provider version evidence, never capture wall time.""" + + model_config = ConfigDict(extra="ignore") + etag: str | None = None + updated: AwareDatetime | None = None + status: str | None = None + + +class CancellationStart(BaseModel): + """Only the original occurrence identity may survive a tombstone read.""" + + model_config = ConfigDict(extra="ignore") + date: Date | None = None + dateTime: AwareDatetime | None = None + timeZone: str | None = None + + @model_validator(mode="after") + def exact_start(self) -> Self: + """Retain exactly one date or instant from the provider identity.""" + if (self.date is None) == (self.dateTime is None): + raise ValueError("Exactly one original start is required") + return self + + +class CancellationIdentity(BaseModel): + """Safe recurrence exclusion fields retained when a body is deleted.""" + + model_config = ConfigDict(extra="ignore") + id: str = Field(min_length=1) + status: Literal["cancelled"] + recurringEventId: str = Field(min_length=1) + originalStartTime: CancellationStart + + +def choose(records: tuple[SourceRecord, ...]) -> SourceRecord: + """Resolve at most one raw and one expanded record; denied content never falls through.""" + if any( + r.content_access != "available" or r.removal_reason == "access_revoked" for r in records + ): + raise RecordNotFound("Calendar event is unavailable") + if any( + r.identity.record_type == "event" and r.removal_reason == "scope_removed" for r in records + ): + raise RecordNotFound("Calendar event is unavailable") + candidates = tuple(r for r in records if r.removal_reason != "scope_removed") + if not candidates: + raise RecordNotFound("Calendar event has not been captured or is unavailable") + if len(candidates) == 1: + return candidates[0] + raw, expanded = sorted(candidates, key=lambda r: r.identity.record_type != "event") + try: + a, b = Version.model_validate(raw.payload), Version.model_validate(expanded.payload) + except ValidationError: + raise CalendarReadIncomplete("Calendar observations need a refresh") from None + same_state = (raw.deleted_at is not None or a.status == "cancelled") == ( + expanded.deleted_at is not None or b.status == "cancelled" + ) and a.status == b.status + fields = ("start", "end", "recurringEventId", "originalStartTime") + same_schedule = all(raw.payload.get(key) == expanded.payload.get(key) for key in fields) + if a.etag and a.etag == b.etag and not (same_state and same_schedule): + raise CalendarReadIncomplete("Calendar observations disagree; refresh this calendar") + if a.updated and b.updated and a.updated != b.updated: + return raw if a.updated > b.updated else expanded + if ( + same_state + and same_schedule + and ((a.etag and a.etag == b.etag) or raw.payload == expanded.payload) + ): + return raw + raise CalendarReadIncomplete("Calendar observations disagree; refresh this calendar") + + +async def read_event( + db: AsyncSession, organization_id: UUID, sync_id: UUID, calendar_id: str, event_id: str +) -> SourceRecord: + """Use the existing compound identity index and current calendar visibility.""" + scope = select(Sync.observed_change_sequence).where( + Sync.organization_id == organization_id, Sync.id == sync_id + ) + sequence = await db.scalar(scope) + parent = await db.scalar( + select(Entity.id).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.entity_definition_short_name == "calendar", + Entity.native_id == calendar_id, + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + content_is_available(), + ) + ) + if sequence is None or parent is None: + raise RecordNotFound("Calendar is unavailable") + identity = RecordIdentity(record_type="event", container_id=calendar_id, native_id=event_id) + rows = ( + await db.scalars( + select(Entity) + .where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.entity_id == identity.entity_key, + Entity.entity_definition_short_name.in_(("event", "event_occurrence")), + Entity.record_revision > 0, + parent_is_visible(), + ) + .limit(2) + ) + ).all() + result = choose(tuple(source_record(row) for row in rows)) + db.expire_all() + if await db.scalar(scope) != sequence: + raise CalendarChanged("Calendar changed while reading; try again") + if result.deleted_at is not None: + try: + identity = ( + CancellationIdentity.model_validate(result.payload).model_dump( + mode="json", exclude_none=True + ) + if is_cancelled_recurring_event(result.payload) + else {} + ) + except ValidationError: + raise CalendarReadIncomplete("Calendar cancellation identity needs a refresh") from None + return result.model_copy(update={"payload": identity, "blobs": ()}) + return result diff --git a/backend/airweave/domains/entities/canonical/calendar_query.py b/backend/airweave/domains/entities/canonical/calendar_query.py new file mode 100644 index 0000000000..816f639d01 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/calendar_query.py @@ -0,0 +1,381 @@ +"""Bounded, store-only calendar ranges over provider-expanded original occurrences.""" + +import json +from datetime import datetime, time, timedelta, timezone +from typing import Literal +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from jose import JWTError, jwt +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError, model_validator +from sqlalchemy import String, and_, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.calendar import CalendarScopeContext +from airweave.domains.entities.canonical.cycle_models import CYCLE_KEY, CaptureCycle +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.query import InvalidRecordCursor, RecordNotFound +from airweave.domains.entities.canonical.scope_execution import ScopeExecution +from airweave.domains.entities.canonical.store import ( + CanonicalStoreError, + SourceNotFound, + content_is_available, + source_record, +) +from airweave.models import Entity, Sync, SyncCursor, SyncJob +from airweave.models.capture_scan import CaptureScan +from airweave.platform.cursors.google_calendar import CalendarWindowCoverage + + +class CalendarRangeError(CanonicalStoreError): + """Calendar reads cannot silently fetch providers or truncate their input.""" + + code = "calendar_range_budget_exceeded" + + +class CalendarRangeNotCaptured(CanonicalStoreError): + """Ask for an explicit configured capture before reading this interval.""" + + code = "calendar_range_not_captured" + + def __init__(self, start: datetime, end: datetime): + """Carry a machine-readable explicit sync request, without executing it.""" + super().__init__( + "Requested range is not captured; configure occurrence_window and run sync" + ) + self.action = { + "operation": "sync_calendar_window", + "config": {"occurrence_window": {"start": start.isoformat(), "end": end.isoformat()}}, + } + + +class CalendarChanged(CanonicalStoreError): + """A continuation cannot mix changed local records or coverage generations.""" + + code = "calendar_changed_restart" + + +class CalendarRange(BaseModel): + """Fixed request window and ordering; only cursor position varies between pages.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + start: AwareDatetime + end: AwareDatetime + timezone: str + limit: int = Field(default=100, ge=1, le=250) + cursor: str | None = None + + @model_validator(mode="after") + def validate_window(self): + """Validate timezone and bounded half-open interval.""" + if not timedelta(0) < self.end - self.start <= timedelta(days=31): + raise ValueError("Calendar read window must be positive and at most31days") + try: + ZoneInfo(self.timezone) + except ZoneInfoNotFoundError as exc: + raise ValueError("Unknown calendar timezone") from exc + return self + + +class CalendarRangeCursor(BaseModel): + """Signed scope, exact query, checkpoint and stable position.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + purpose: Literal["canonical_calendar_range_v1"] = "canonical_calendar_range_v1" + organization_id: UUID + sync_id: UUID + calendar_id: str + start: AwareDatetime + end: AwareDatetime + timezone: str + sequence: int + coverage_scan_id: UUID + after_start: AwareDatetime + after_id: UUID + + +class CalendarRangePage(BaseModel): + """Current stored occurrences alongside their last successful coverage evidence.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + records: tuple[SourceRecord, ...] + next_cursor: str | None + has_more: bool + coverage: CalendarWindowCoverage + consistency: Literal["observed"] = "observed" + refresh_state: Literal["last_completed", "refresh_in_progress", "refresh_incomplete"] + observed_change_sequence: int + scanned_records: int + + +def occurrence_interval(payload: dict, calendar_timezone: str) -> tuple[datetime, datetime]: + """All-day dates remain native; only comparison uses their calendar timezone.""" + start, end = payload.get("start", {}), payload.get("end", {}) + zone = ZoneInfo(calendar_timezone) + if start.get("date") and end.get("date"): + from datetime import date + + return ( + datetime.combine(date.fromisoformat(start["date"]), time(), zone), + datetime.combine(date.fromisoformat(end["date"]), time(), zone), + ) + values = [] + for part in (start, end): + value = datetime.fromisoformat(part["dateTime"].replace("Z", "+00:00")) + if value.tzinfo is None: + value = value.replace(tzinfo=ZoneInfo(part.get("timeZone") or calendar_timezone)) + values.append(value) + return values[0], values[1] + + +class CalendarRangeService: + """Read current owned rows under explicit input bounds and optimistic consistency.""" + + def __init__(self, signing_key: str): + """Use the deployment cursor signing key.""" + self.signing_key = signing_key + + async def read( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + calendar_id: str, + query: CalendarRange, + ) -> CalendarRangePage: + """Return a bounded observed range, failing on changed continuation state.""" + sync = await db.scalar( + select(Sync).where(Sync.id == sync_id, Sync.organization_id == organization_id) + ) + if sync is None: + raise SourceNotFound("Source does not exist in this organization") + calendar = await db.scalar( + select(Entity).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.entity_definition_short_name == "calendar", + Entity.native_id == calendar_id, + Entity.deleted_at.is_(None), + content_is_available(), + ) + ) + if calendar is None: + raise RecordNotFound("Calendar is not currently available") + coverage, refresh_state = await self._coverage(db, sync, calendar, query) + sequence = sync.observed_change_sequence + position = self._position(query, organization_id, sync_id, calendar_id, coverage, sequence) + rows = await self._bounded_rows(db, organization_id, sync_id, calendar_id) + matches = self._matching_rows(rows, coverage, query, position) + # Recheck both journal and checkpoint: no snapshot or mixed-page claim. + calendar_record_id = calendar.id + db.expire_all() + latest = await db.scalar( + select(Sync).where(Sync.id == sync_id, Sync.organization_id == organization_id) + ) + latest_calendar = await db.scalar( + select(Entity).where(Entity.id == calendar_record_id, content_is_available()) + ) + if latest is None or latest_calendar is None or latest.observed_change_sequence != sequence: + raise CalendarChanged("Calendar changed while reading; restart range query") + latest_coverage, refresh_state = await self._coverage(db, latest, latest_calendar, query) + if latest_coverage != coverage: + raise CalendarChanged("Calendar coverage changed while reading; restart range query") + more = len(matches) > query.limit + page = matches[: query.limit] + next_cursor = None + if more: + next_cursor = jwt.encode( + CalendarRangeCursor( + organization_id=organization_id, + sync_id=sync_id, + calendar_id=calendar_id, + start=query.start, + end=query.end, + timezone=query.timezone, + sequence=sequence, + coverage_scan_id=coverage.scan_id, + after_start=page[-1][0], + after_id=page[-1][1], + ).model_dump(mode="json"), + self.signing_key, + algorithm="HS256", + ) + return CalendarRangePage( + records=tuple(item[2] for item in page), + next_cursor=next_cursor, + has_more=more, + coverage=coverage, + refresh_state=refresh_state, + observed_change_sequence=sequence, + scanned_records=len(rows), + ) + + @staticmethod + async def _coverage( + db: AsyncSession, sync: Sync, calendar: Entity, query: CalendarRange + ) -> tuple[CalendarWindowCoverage, str]: + """Only completed exact-scope publication attests this native occurrence window.""" + cursor = await db.scalar( + select(SyncCursor).where( + SyncCursor.sync_id == sync.id, SyncCursor.organization_id == sync.organization_id + ) + ) + raw_cycle = cursor.cursor_data.get(CYCLE_KEY) if cursor else None + scan = await db.scalar( + select(CaptureScan).where( + CaptureScan.sync_id == sync.id, + CaptureScan.organization_id == sync.organization_id, + CaptureScan.record_type == "event_occurrence", + CaptureScan.container_id == calendar.native_id, + CaptureScan.parent_record_id == calendar.id, + CaptureScan.parent_visibility_epoch == calendar.visibility_epoch, + ) + ) + if not raw_cycle or scan is None or not scan.execution_state: + raise CalendarRangeNotCaptured(query.start, query.end) + cycle = CaptureCycle.model_validate(raw_cycle) + execution = ScopeExecution.model_validate(scan.execution_state) + published = execution.published + if ( + cycle.mode != "mixed" + or scan.fingerprint != cycle.configuration.fingerprint + or published is None + or published.parent_visibility_epoch != calendar.visibility_epoch + or published.policy != "exhaustive" + ): + raise CalendarRangeNotCaptured(query.start, query.end) + context = CalendarScopeContext.model_validate(published.request_context) + try: + coverage = CalendarWindowCoverage( + start=context.parameters["timeMin"], + end=context.parameters["timeMax"], + timezone=context.timezone, + completed_at=published.completed_at, + scan_id=published.sweep_id, + ) + except (KeyError, ValidationError) as error: + raise CalendarRangeNotCaptured(query.start, query.end) from error + if query.start < coverage.start or query.end > coverage.end: + raise CalendarRangeNotCaptured(query.start, query.end) + if calendar.source_payload.get("timeZone") != coverage.timezone: + raise CalendarChanged("Calendar timezone changed; refresh captured window") + refresh_state = "last_completed" + if published.writer_attempt_id is None or published.observed_change_sequence is None: + return coverage, "refresh_incomplete" + if ( + published.writer_attempt_id != sync.writer_attempt_id + or published.observed_change_sequence != sync.observed_change_sequence + or scan.cycle_id != cycle.version.cycle_id + or scan.phase != "complete" + or scan.sweep_id != published.sweep_id + ): + job = await db.get(SyncJob, sync.writer_job_id) if sync.writer_job_id else None + refresh_state = ( + "refresh_in_progress" + if job and job.status in {"running", "pending"} + else "refresh_incomplete" + ) + return coverage, refresh_state + + @staticmethod + async def _bounded_rows( + db: AsyncSession, organization_id: UUID, sync_id: UUID, calendar_id: str + ) -> list[Entity]: + """Gate payload transfer and count in one PostgreSQL statement snapshot.""" + filters = ( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + Entity.entity_definition_short_name == "event_occurrence", + Entity.container_id == calendar_id, + Entity.deleted_at.is_(None), + content_is_available(), + ) + budget = ( + select( + func.count(Entity.id).label("records"), + func.coalesce( + func.sum(func.octet_length(cast(Entity.source_payload, String))), 0 + ).label("bytes"), + ) + .where(*filters) + .cte("occurrence_budget") + ) + # Outer join always returns the budget, even for an empty or rejected scope. + # An oversized single JSON value never travels to the application. + result = await db.execute( + select(budget.c.records, budget.c.bytes, Entity) + .select_from(budget) + .outerjoin( + Entity, + and_(*filters, budget.c.records <= 10000, budget.c.bytes <= 20 * 1024 * 1024), + ) + .order_by(Entity.id) + ) + data = result.all() + if data[0][0] > 10000 or data[0][1] > 20 * 1024 * 1024: + raise CalendarRangeError( + "Stored occurrence input exceeds budget; narrow capture horizon" + ) + return [row[2] for row in data if row[2] is not None] + + @staticmethod + def _matching_rows( + rows: list[Entity], + coverage: CalendarWindowCoverage, + query: CalendarRange, + position: CalendarRangeCursor | None, + ) -> list[tuple[datetime, UUID, SourceRecord]]: + """Apply schedule semantics only after bounding the calendar's stored inputs.""" + bytes_read = 0 + matches = [] + for row in rows: + bytes_read += len(json.dumps(row.source_payload).encode()) + if bytes_read > 20 * 1024 * 1024: + raise CalendarRangeError("Stored occurrence input exceeds byte budget") + if row.source_payload.get("status") == "cancelled": + continue + try: + start, end = occurrence_interval(row.source_payload, coverage.timezone) + if end <= start: + raise ValueError("Invalid interval") + except (KeyError, ValueError, TypeError) as exc: + raise CalendarRangeError( + "Captured occurrence has an invalid schedule; refresh source" + ) from exc + start = start.astimezone(timezone.utc) + if end > query.start and start < query.end: + if position is None or (start, row.id) > (position.after_start, position.after_id): + matches.append((start, row.id, source_record(row))) + matches.sort(key=lambda item: (item[0], item[1])) + return matches + + def _position( + self, + query: CalendarRange, + organization_id: UUID, + sync_id: UUID, + calendar_id: str, + coverage: CalendarWindowCoverage, + sequence: int, + ) -> CalendarRangeCursor | None: + if query.cursor is None: + return None + try: + position = CalendarRangeCursor.model_validate( + jwt.decode(query.cursor, self.signing_key, algorithms=["HS256"]) + ) + except (JWTError, ValidationError, ValueError) as exc: + raise InvalidRecordCursor("Invalid Calendar cursor; restart range query") from exc + if position.purpose != "canonical_calendar_range_v1" or ( + position.organization_id, + position.sync_id, + position.calendar_id, + position.start, + position.end, + position.timezone, + ) != (organization_id, sync_id, calendar_id, query.start, query.end, query.timezone): + raise InvalidRecordCursor("Calendar cursor belongs to another scope or range") + if position.sequence != sequence or position.coverage_scan_id != coverage.scan_id: + raise CalendarChanged("Calendar changed since prior page; restart range query") + return position diff --git a/backend/airweave/domains/entities/canonical/checkpoint.py b/backend/airweave/domains/entities/canonical/checkpoint.py new file mode 100644 index 0000000000..d67f46f8be --- /dev/null +++ b/backend/airweave/domains/entities/canonical/checkpoint.py @@ -0,0 +1,13 @@ +"""Capture-owned checkpoint metadata, stamped only at the fenced commit boundary.""" + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class CanonicalCheckpoint(BaseModel): + """Bind successful source state to its actual committed run and journal position.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + writer_attempt_id: UUID + observed_change_sequence: int = Field(ge=0) diff --git a/backend/airweave/domains/entities/canonical/coverage.py b/backend/airweave/domains/entities/canonical/coverage.py new file mode 100644 index 0000000000..1e2655b0f8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/coverage.py @@ -0,0 +1,182 @@ +"""Describe persisted capture promises independently of indexing counts or job success.""" + +from uuid import UUID + +from pydantic import ValidationError +from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.coverage_models import ( + CaptureCoverage, + CaptureScopeSummary, + FullCaptureCoverage, +) +from airweave.domains.entities.canonical.cycle_models import ( + CYCLE_KEY, + CaptureCycle, +) +from airweave.domains.entities.canonical.requests import WriterFence +from airweave.domains.entities.canonical.store import content_is_available +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor + + +async def mixed_scope_summary( + db: AsyncSession, organization_id: UUID, sync_id: UUID, cycle: CaptureCycle +) -> CaptureScopeSummary | None: + """Aggregate the current visible forest, withholding counts until roots are refreshed.""" + sync = await db.scalar( + select(Sync).where(Sync.id == sync_id, Sync.organization_id == organization_id) + ) + if sync is None or sync.writer_job_id is None or sync.writer_attempt_id is None: + return None + fence = WriterFence( + organization_id=organization_id, + sync_id=sync_id, + job_id=sync.writer_job_id, + epoch=sync.writer_epoch, + attempt_id=sync.writer_attempt_id, + attempt_number=sync.writer_attempt_number, + ) + common = ( + CaptureScan.organization_id == organization_id, + CaptureScan.sync_id == sync_id, + CaptureScan.cycle_id == cycle.version.cycle_id, + CaptureScan.phase == "complete", + ) + roots = cycle.configuration.root_record_types + inventory_roots = tuple(kind for kind in roots if cycle.configuration.children_of(kind)) + modes: dict[str, str | None] = {} + if roots: + rows = await db.execute( + select( + CaptureScan.record_type, + CaptureScan.execution_state["plan"]["mode"].astext, + ).where( + *common, + CaptureScan.record_type.in_(roots), + CaptureScan.parent_record_id.is_(None), + CaptureScan.container_id.is_(None), + or_( + CaptureScan.record_type.not_in(inventory_roots), + CaptureScan.membership_attempt_id == fence.attempt_id, + ), + ) + ) + modes = dict(rows.tuples().all()) + if not set(roots).issubset(modes): + return None + eligible = len(roots) + full = sum(mode == "full" for mode in modes.values()) + changes = sum(mode == "changes" for mode in modes.values()) + for kind, parents in cycle.configuration.parents.items(): + parent_types = tuple(parent for parent in parents if parent is not None) + if not parent_types: + continue + completed = and_( + *common, + CaptureScan.record_type == kind, + CaptureScan.parent_record_id == Entity.id, + CaptureScan.parent_visibility_epoch == Entity.visibility_epoch, + ) + if cycle.configuration.children_of(kind): + completed = and_(completed, CaptureScan.membership_attempt_id == fence.attempt_id) + counts = ( + await db.execute( + select( + func.count(Entity.id), + func.count( + case((CaptureScan.execution_state["plan"]["mode"].astext == "full", 1)) + ), + func.count( + case((CaptureScan.execution_state["plan"]["mode"].astext == "changes", 1)) + ), + ) + .select_from(Entity) + .outerjoin(CaptureScan, completed) + .where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.entity_definition_short_name.in_(parent_types), + content_is_available(), + ) + ) + ).one() + eligible += counts[0] + full += counts[1] + changes += counts[2] + return CaptureScopeSummary( + eligible=eligible, + completed_full=full, + completed_changes=changes, + unfinished=eligible - full - changes, + ) + + +async def capture_coverage( + db: AsyncSession, organization_id: UUID, sync_ids: tuple[UUID, ...] +) -> dict[UUID, CaptureCoverage]: + """Read only existing tenant-scoped cycle metadata, never create an authority.""" + rows = await db.scalars( + select(SyncCursor).where( + SyncCursor.organization_id == organization_id, SyncCursor.sync_id.in_(sync_ids) + ) + ) + result = {} + for row in rows: + raw = row.cursor_data.get(CYCLE_KEY) + if raw is None: + continue + try: + cycle = CaptureCycle.model_validate(raw) + except ValidationError: + continue # Unknown/corrupt capture state cannot certify coverage. + policies = {kind: cycle.configuration.policy(kind) for kind in cycle.configuration.parents} + discovery = ( + "incomplete" + if any(p != "exhaustive" for p in policies.values()) + else "pending" + if cycle.phase == "active" + else "scope_enumeration_complete" + ) + evidence = cycle.last_full_capture + if evidence is not None and evidence.configuration_digest != cycle.configuration.digest(): + continue + promoted = cycle.promoted_checkpoint + if cycle.mode == "changes": + if ( + evidence is None + or promoted is None + or (promoted.configuration_digest != cycle.configuration.digest()) + ): + continue + discovery = evidence.discovery + summary = ( + await mixed_scope_summary(db, organization_id, row.sync_id, cycle) + if cycle.mode == "mixed" + else None + ) + if cycle.mode == "mixed" and ( + evidence is not None + or promoted is not None + or (cycle.phase == "complete" and (summary is None or summary.unfinished != 0)) + ): + continue # Withhold unknown/incompatible coverage instead of emitting a false promise. + result[row.sync_id] = CaptureCoverage( + phase=cycle.phase, + policies=policies, + discovery=discovery, + mode=cycle.mode, + scope_summary=summary, + last_full_capture=FullCaptureCoverage( + cycle_id=evidence.cycle_id, + completed_at=evidence.completed_at, + discovery=evidence.discovery, + ) + if evidence + else None, + provider_checkpoint_promoted_at=promoted.promoted_at if promoted else None, + ) + return result diff --git a/backend/airweave/domains/entities/canonical/coverage_models.py b/backend/airweave/domains/entities/canonical/coverage_models.py new file mode 100644 index 0000000000..5f54350506 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/coverage_models.py @@ -0,0 +1,47 @@ +"""Public capture coverage facts, independent of SQL and runtime settings.""" + +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +CompletionPolicy = Literal["exhaustive", "discovery_only", "discovery_with_validation"] + + +class FullCaptureCoverage(BaseModel): + """Public coverage evidence excludes private provider continuation values.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + cycle_id: UUID + completed_at: AwareDatetime + discovery: Literal["incomplete", "scope_enumeration_complete"] + + +class CaptureScopeSummary(BaseModel): + """Counts describe eligible exact scopes, never original/indexed record totals.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + eligible: int = Field(ge=0, strict=True) + completed_full: int = Field(ge=0, strict=True) + completed_changes: int = Field(ge=0, strict=True) + unfinished: int = Field(ge=0, strict=True) + + @model_validator(mode="after") + def partition(self): + """Every eligible scope belongs to exactly one progress category.""" + if self.eligible != self.completed_full + self.completed_changes + self.unfinished: + raise ValueError("Scope progress must partition eligible scopes") + return self + + +class CaptureCoverage(BaseModel): + """No cycle means unknown; completion only certifies the declared scope policies.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + phase: Literal["active", "complete"] + mode: Literal["full", "changes", "mixed"] = "full" + scope_summary: CaptureScopeSummary | None = None + last_full_capture: FullCaptureCoverage | None = None + provider_checkpoint_promoted_at: AwareDatetime | None = None + policies: dict[str, CompletionPolicy] + discovery: Literal["incomplete", "pending", "scope_enumeration_complete"] diff --git a/backend/airweave/domains/entities/canonical/cycle_models.py b/backend/airweave/domains/entities/canonical/cycle_models.py new file mode 100644 index 0000000000..26b0912ed7 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/cycle_models.py @@ -0,0 +1,298 @@ +"""Typed engine-owned cycle state inside the existing SyncCursor.""" + +import hashlib +import json +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import ( + AfterValidator, + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + field_validator, + model_validator, +) + +from airweave.domains.entities.canonical.coverage_models import CompletionPolicy +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import RecordKind, ScanVersion, WriterFence + +CaptureMode = Literal["full", "changes", "mixed"] +CompletedDiscovery = Literal["incomplete", "scope_enumeration_complete"] + +CYCLE_KEY = "canonical_cycle" +TERMINAL_CHECKPOINT_KEY = "_canonical_terminal_checkpoint" + + +def bounded_source_plan(value: dict[str, JsonValue]) -> dict[str, JsonValue]: + """Immutable cycle parameters must not become a response archive.""" + if len(json.dumps(value, allow_nan=False).encode()) > 65536: + raise ValueError("Cycle source plan exceeds64KiB") + return value + + +SourcePlan = Annotated[dict[str, JsonValue], AfterValidator(bounded_source_plan)] + + +class CycleConfiguration(BaseModel): + """Immutable copy of the source's allowed record-parent relationships.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + parents: dict[RecordKind, tuple[RecordKind | None, ...]] + completion_policies: dict[RecordKind, CompletionPolicy] = Field(default_factory=dict) + known_object_validation: tuple[RecordKind, ...] = () + scope_changes: tuple[RecordKind, ...] = () + exact_parent_validation: tuple[RecordKind, ...] = () + membership: Literal["retained", "observed"] = "retained" + + def digest(self) -> str: + """Bind history to scope, topology and guarantees, including default policies.""" + value = self.model_dump(mode="json") + if self.membership == "retained": + value.pop("membership") # Preserve existing provider checkpoint digests. + if not self.known_object_validation: + value.pop("known_object_validation") # Preserve existing default configuration digests. + if not self.exact_parent_validation: + value.pop("exact_parent_validation") + if not self.scope_changes: + value.pop("scope_changes") + value["completion_policies"] = {kind: self.policy(kind) for kind in self.parents} + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + def policy(self, kind: str) -> CompletionPolicy: + """Omitted declarations preserve historical exhaustive semantics.""" + return self.completion_policies.get(kind, "exhaustive") + + @classmethod + def from_source( + cls, + *, + fingerprint: str, + record_types: tuple[str, ...], + container_parents: dict[str, str | tuple[str | None, ...]], + completion_policies: dict[RecordKind, CompletionPolicy] | None = None, + known_object_validation: tuple[RecordKind, ...] = (), + scope_changes: tuple[RecordKind, ...] = (), + exact_parent_validation: tuple[RecordKind, ...] = (), + ) -> "CycleConfiguration": + """Snapshot the existing source declaration; no second topology registry.""" + if len(set(record_types)) != len(record_types): + raise ValueError("Duplicate source record kind") + if set(container_parents) - set(record_types): + raise ValueError("Parent relationship has an undeclared source record kind") + parents = {} + for kind in record_types: + declared = container_parents.get(kind, (None,)) + parents[kind] = (declared,) if isinstance(declared, str) else declared + return cls( + fingerprint=fingerprint, + parents=parents, + completion_policies=completion_policies or {}, + known_object_validation=known_object_validation, + scope_changes=scope_changes, + exact_parent_validation=exact_parent_validation, + ) + + @model_validator(mode="before") + @classmethod + def upgrade_flat(cls, value): + """Read schema1 flat declarations without retaining a competing topology.""" + if isinstance(value, dict) and "root_record_type" in value: + upgraded = dict(value) + root = upgraded.pop("root_record_type") + children = upgraded.pop("child_record_types", ()) + if not isinstance(children, (list, tuple)): + raise ValueError("Legacy child kinds must be an array") + if "parents" in upgraded or root in children or len(set(children)) != len(children): + raise ValueError("Ambiguous capture topology") + upgraded["parents"] = {root: (None,), **{child: (root,) for child in children}} + return upgraded + return value + + @model_validator(mode="after") + def reachable_types(self) -> "CycleConfiguration": + """Type cycles are valid; every declared kind must be reachable from a root.""" + if len(set(self.known_object_validation)) != len(self.known_object_validation) or ( + set(self.known_object_validation) - set(self.parents) + ): + raise ValueError("Known-object validation requires unique declared kinds") + if len(set(self.scope_changes)) != len(self.scope_changes) or any( + kind not in self.parents or self.children_of(kind) for kind in self.scope_changes + ): + raise ValueError("Scope changes require unique declared leaf kinds") + if set(self.completion_policies) - set(self.parents): + raise ValueError("Completion policy has an undeclared record kind") + if not self.parents or any(not kind or not values for kind, values in self.parents.items()): + raise ValueError("Capture topology must declare kinds and their parents") + if any(len(set(values)) != len(values) for values in self.parents.values()): + raise ValueError("Duplicate allowed parent kind") + if any( + parent is not None and parent not in self.parents + for values in self.parents.values() + for parent in values + ): + raise ValueError("Unknown parent kind") + reachable = {kind for kind, values in self.parents.items() if None in values} + while True: + expanded = reachable | { + kind + for kind, values in self.parents.items() + if any(parent in reachable for parent in values) + } + if expanded == reachable: + break + reachable = expanded + if reachable != set(self.parents): + raise ValueError("Capture kinds must be reachable from a root") + return self + + @model_validator(mode="after") + def valid_exact_parent_kinds(self) -> "CycleConfiguration": + """Root inventories always revalidate discovery before descendant work.""" + if len(set(self.exact_parent_validation)) != len(self.exact_parent_validation) or any( + kind not in self.parents or None in self.parents[kind] or not self.children_of(kind) + for kind in self.exact_parent_validation + ): + raise ValueError("Exact parent validation requires unique nonroot owner kinds") + if self.exact_parent_validation and self.scope_changes: + raise ValueError("Exact parent validation does not support mixed scope plans") + return self + + @property + def root_record_types(self) -> tuple[str, ...]: + """Kinds permitting records without a parent.""" + return tuple(kind for kind, parents in self.parents.items() if None in parents) + + def fresh_inventory(self, kind: str) -> bool: + """Exact owner reads replace attempt-wide inventory freshness only by opt-in.""" + return bool(self.children_of(kind)) and kind not in self.exact_parent_validation + + def children_of(self, kind: str) -> tuple[str, ...]: + """Scopes required for a visible record of this kind.""" + return tuple(child for child, parents in self.parents.items() if kind in parents) + + +class CycleVersion(BaseModel): + """Exact checkpoint version observed by the caller.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + cycle_id: UUID + revision: int = Field(ge=1) + + +class ProviderCheckpoint(BaseModel): + """Bounded connector state; distinct from the engine's change-sequence checkpoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + value: dict[str, JsonValue] + + @field_validator("value") + @classmethod + def bounded(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + """Never store an unbounded provider response or work queue.""" + if not value or len(json.dumps(value, allow_nan=False).encode()) > 65536: + raise ValueError("Provider checkpoint must contain at most 64 KiB of state") + return value + + +class FullCaptureEvidence(BaseModel): + """A completed full pass certifies only its explicit discovery guarantee.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + cycle_id: UUID + completed_at: AwareDatetime + discovery: CompletedDiscovery + configuration_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class PromotedCheckpoint(BaseModel): + """Only successful cycle finalization advances this private provider boundary.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + checkpoint: ProviderCheckpoint + configuration_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + promoted_at: AwareDatetime + + +class TerminalCheckpoint(BaseModel): + """Source-parsed boundary from an exact completed root scan, attested during publication.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_type: RecordKind + expected: ScanVersion + checkpoint: ProviderCheckpoint + + +class CaptureCycle(BaseModel): + """Completion stays durable until an explicit next-cycle CAS.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[2] = 2 + version: CycleVersion + configuration: CycleConfiguration + phase: Literal["active", "complete"] = "active" + completed_job_id: UUID | None = None + mode: CaptureMode = "full" + starting_checkpoint: ProviderCheckpoint | None = None + source_plan: SourcePlan = Field(default_factory=dict) + force_full_scopes: bool = False + promoted_checkpoint: PromotedCheckpoint | None = None + last_full_capture: FullCaptureEvidence | None = None + + @model_validator(mode="before") + @classmethod + def upgrade_version(cls, value): + """Flat persisted cycles normalize through the same configuration boundary.""" + if isinstance(value, dict) and value.get("schema_version") == 1: + upgraded = {**value, "schema_version": 2} + upgraded.pop("root_writer_attempt_id", None) + return upgraded + return value + + +class BeginCycle(BaseModel): + """Resume an active cycle or CAS-advance its completed predecessor.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + configuration: CycleConfiguration + expected: CycleVersion | None = None + mode: CaptureMode = "full" + starting_checkpoint: ProviderCheckpoint | None = None + source_plan: SourcePlan = Field(default_factory=dict) + force_full_scopes: bool = False + + +class CompleteCycle(BaseModel): + """Publish only an unchanged active cycle with all required scans complete.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + expected: CycleVersion + terminal_checkpoint: TerminalCheckpoint | None = None + + +class RestartCycle(BaseModel): + """Explicitly abandon the exact active cycle without certifying its partial capture.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + expected: CycleVersion + configuration: CycleConfiguration + mode: CaptureMode = "full" + starting_checkpoint: ProviderCheckpoint | None = None + source_plan: SourcePlan = Field(default_factory=dict) + force_full_scopes: bool = False + + +class ScopeWork(BaseModel): + """An eligible incomplete inventory and its exact captured owner, never a durable queue.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_type: str + parent: SourceRecord | None = None + parent_visibility_epoch: int | None = None diff --git a/backend/airweave/domains/entities/canonical/cycle_store.py b/backend/airweave/domains/entities/canonical/cycle_store.py new file mode 100644 index 0000000000..31255b0bdb --- /dev/null +++ b/backend/airweave/domains/entities/canonical/cycle_store.py @@ -0,0 +1,576 @@ +"""Cycle ownership on the existing sync cursor; callers already hold the Sync fence.""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import and_, exists, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from airweave.domains.entities.canonical.checkpoint import CanonicalCheckpoint +from airweave.domains.entities.canonical.cycle_models import ( + CYCLE_KEY, + TERMINAL_CHECKPOINT_KEY, + BeginCycle, + CaptureCycle, + CompleteCycle, + CycleVersion, + FullCaptureEvidence, + PromotedCheckpoint, + ProviderCheckpoint, + RestartCycle, + ScopeWork, +) +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity, WriterFence +from airweave.domains.entities.canonical.store import ( + CanonicalStoreError, + ancestor_chain, + content_is_available, + source_record, +) +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor + + +class CycleConflict(CanonicalStoreError): + """Reload the active cycle; do not guess recovery or erase engine state.""" + + code = "cycle_conflict" + + +async def cursor_row(db: AsyncSession, fence: WriterFence) -> SyncCursor | None: + """The caller holds the same sync lock as all capture mutations.""" + return await db.scalar( + select(SyncCursor) + .where( + SyncCursor.sync_id == fence.sync_id, + SyncCursor.organization_id == fence.organization_id, + ) + .execution_options(populate_existing=True) + ) + + +def cycle_state(cursor: SyncCursor | None) -> CaptureCycle | None: + """Reject malformed state rather than falling back to a new cycle.""" + if cursor is None or CYCLE_KEY not in cursor.cursor_data: + return None + return CaptureCycle.model_validate(cursor.cursor_data[CYCLE_KEY]) + + +def persist(cursor: SyncCursor, state: CaptureCycle) -> None: + """Preserve provider checkpoint fields until finalization.""" + cursor.cursor_data = {**cursor.cursor_data, CYCLE_KEY: state.model_dump(mode="json")} + cursor.last_updated = datetime.now(timezone.utc) + + +def next_cycle(previous: CaptureCycle | None, request: BeginCycle | RestartCycle) -> CaptureCycle: + """One immutable execution plan; incompatible history never becomes a delta baseline.""" + digest = request.configuration.digest() + evidence = previous.last_full_capture if previous else None + promoted = previous.promoted_checkpoint if previous else None + if evidence is not None and evidence.configuration_digest != digest: + evidence = None + if promoted is not None and promoted.configuration_digest != digest: + promoted = None + if request.mode == "mixed": + if not request.configuration.scope_changes or request.starting_checkpoint is not None: + raise CycleConflict("Mixed capture requires declared scope changes and no global token") + evidence = promoted = None + elif request.configuration.scope_changes: + raise CycleConflict("Scoped changes require explicit mixed cycle execution") + starting = request.starting_checkpoint + if request.mode == "changes": + if len(request.configuration.parents) != 1 or next( + iter(request.configuration.parents.values()) + ) != (None,): + raise CycleConflict("Changes currently require one independent root kind") + if evidence is None or promoted is None: + raise CycleConflict( + "Changes require a completed compatible full capture and checkpoint" + ) + if starting is not None and starting != promoted.checkpoint: + raise CycleConflict("Changes must start at the last promoted provider checkpoint") + starting = promoted.checkpoint + return CaptureCycle( + version=CycleVersion( + cycle_id=uuid4(), revision=1 if previous is None else previous.version.revision + 1 + ), + configuration=request.configuration, + mode=request.mode, + starting_checkpoint=starting, + source_plan=request.source_plan, + force_full_scopes=request.force_full_scopes, + promoted_checkpoint=promoted, + last_full_capture=evidence, + ) + + +def attest_active_plan(state: CaptureCycle, request: BeginCycle) -> None: + """Resume never changes the already persisted execution intent.""" + if state.configuration != request.configuration: + raise CycleConflict("Active cycle configuration changed; explicit abandonment required") + if state.force_full_scopes != request.force_full_scopes: + raise CycleConflict("Active cycle force-full intent changed") + if state.source_plan != request.source_plan: + raise CycleConflict("Active cycle source plan changed; explicit restart required") + if state.mode != request.mode or ( + request.starting_checkpoint is not None + and request.starting_checkpoint != state.starting_checkpoint + ): + raise CycleConflict("Active cycle execution plan changed; explicit restart required") + + +async def begin_cycle(db: AsyncSession, request: BeginCycle) -> CaptureCycle: + """An explicit completed-version CAS starts the next cycle.""" + cursor = await cursor_row(db, request.fence) + state = cycle_state(cursor) + if state: + if request.expected is not None and request.expected != state.version: + raise CycleConflict("Cycle changed; reload durable progress") + if state.phase == "active": + attest_active_plan(state, request) + return state + if request.expected is None: + return state + elif request.expected is not None: + raise CycleConflict("Expected cycle does not exist") + if cursor is None: + cursor = SyncCursor( + organization_id=request.fence.organization_id, + sync_id=request.fence.sync_id, + cursor_data={}, + ) + db.add(cursor) + state = next_cycle(state, request) + persist(cursor, state) + await db.flush() + return state + + +async def attest_cycle( + db: AsyncSession, fence: WriterFence, cycle_id: UUID +) -> tuple[SyncCursor, CaptureCycle]: + """Reject obsolete or foreign cycle identities before any scope mutation.""" + cursor = await cursor_row(db, fence) + state = cycle_state(cursor) + if state is None or state.phase != "active" or state.version.cycle_id != cycle_id: + raise CycleConflict("Scan does not belong to the active cycle") + return cursor, state + + +def inventory_complete(subject, fence: WriterFence, state: CaptureCycle): + """Exact inventory that captured a parent must be fresh for this writer attempt.""" + owner = aliased(Entity) + parent_id = ( + select(owner.id) + .where( + owner.organization_id == subject.organization_id, + owner.sync_id == subject.sync_id, + owner.entity_definition_short_name == subject.parent_record_type, + owner.native_id == subject.parent_native_id, + owner.container_id.is_not_distinct_from(subject.parent_container_id), + ) + .correlate(subject) + .scalar_subquery() + ) + sightings = ( + (subject.last_seen_run_id == CaptureScan.sweep_id,) + if state.configuration.membership == "observed" + else () + ) + return exists( + select(CaptureScan.id).where( + *sightings, + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.record_type == subject.entity_definition_short_name, + CaptureScan.container_id.is_not_distinct_from(subject.container_id), + CaptureScan.parent_record_id.is_not_distinct_from(parent_id), + CaptureScan.parent_visibility_epoch.is_not_distinct_from( + subject.parent_visibility_epoch + ), + or_( + subject.entity_definition_short_name.in_( + state.configuration.exact_parent_validation + ), + CaptureScan.membership_attempt_id == fence.attempt_id, + ), + or_( + subject.parent_record_type.is_(None), + subject.parent_record_type.not_in(state.configuration.exact_parent_validation), + and_( + CaptureScan.parent_verified_attempt_id == fence.attempt_id, + CaptureScan.parent_verified_revision + == select(owner.record_revision) + .where(owner.id == parent_id) + .correlate(subject) + .scalar_subquery(), + ), + ), + CaptureScan.phase == "complete", + ) + ) + + +def membership_ready(fence: WriterFence, state: CaptureCycle): + """Current parent and every ancestor need completed current-attempt inventories.""" + chain = ancestor_chain() + ancestor = aliased(Entity) + stale_ancestor = exists( + select(chain.c.id) + .join(ancestor, ancestor.id == chain.c.id) + .where(~inventory_complete(ancestor, fence, state)) + ) + return and_(inventory_complete(Entity, fence, state), ~stale_ancestor) + + +def work_membership_ready(fence: WriterFence, state: CaptureCycle): + """Early work needs a seen exact-read owner; completion still requires full inventories.""" + if state.mode != "full" or not state.configuration.exact_parent_validation: + return membership_ready(fence, state) + owner = aliased(Entity) + early = exists( + select(CaptureScan.id) + .join(owner, owner.id == CaptureScan.parent_record_id) + .where( + Entity.entity_definition_short_name.in_(state.configuration.exact_parent_validation), + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.record_type == Entity.entity_definition_short_name, + CaptureScan.container_id.is_not_distinct_from(Entity.container_id), + CaptureScan.phase.in_(("collecting", "reconciling")), + Entity.last_seen_run_id == CaptureScan.sweep_id, + owner.organization_id == Entity.organization_id, + owner.sync_id == Entity.sync_id, + owner.entity_definition_short_name == Entity.parent_record_type, + owner.native_id == Entity.parent_native_id, + owner.container_id.is_not_distinct_from(Entity.parent_container_id), + CaptureScan.parent_visibility_epoch == owner.visibility_epoch, + Entity.parent_visibility_epoch == owner.visibility_epoch, + ) + ) + chain = ancestor_chain() + ancestor = aliased(Entity) + stale_ancestor = exists( + select(chain.c.id) + .join(ancestor, ancestor.id == chain.c.id) + .where(~inventory_complete(ancestor, fence, state)) + ) + return and_(or_(inventory_complete(Entity, fence, state), early), ~stale_ancestor) + + +async def root_ready(db: AsyncSession, fence: WriterFence, state: CaptureCycle) -> bool: + """Every declared root inventory completes before descendant work.""" + for kind in state.configuration.root_record_types: + predicates = [ + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.record_type == kind, + CaptureScan.container_id.is_(None), + CaptureScan.parent_record_id.is_(None), + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.phase == "complete", + ] + if state.configuration.fresh_inventory(kind): + predicates.append(CaptureScan.membership_attempt_id == fence.attempt_id) + if await db.scalar(select(CaptureScan.id).where(*predicates).limit(1)) is None: + return False + return True + + +async def scope_owner( + db: AsyncSession, fence: WriterFence, state: CaptureCycle, scope: CompletedScope +) -> Entity | None: + """Resolve full parent identity; only old unambiguous flat requests are normalized.""" + allowed = state.configuration.parents.get(scope.record_type) + if allowed is None: + raise CycleConflict("Scope kind is not declared by the active cycle") + identity = scope.parent + if identity is None: + if scope.container_id is None and None in allowed: + return None + flat = tuple(parent for parent in allowed if parent is not None) + if ( + len(flat) != 1 + or state.configuration.parents[flat[0]] != (None,) + or scope.container_id is None + ): + raise CycleConflict("Nested scope requires its exact parent identity") + identity = RecordIdentity(record_type=flat[0], native_id=scope.container_id) + if identity.record_type not in allowed: + raise CycleConflict("Scope parent kind is not declared by the active cycle") + parent = await db.scalar( + select(Entity) + .where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name == identity.record_type, + Entity.entity_id == identity.entity_key, + Entity.record_revision > 0, + ) + .execution_options(populate_existing=True) + ) + if parent is None: + raise CycleConflict("Scope parent is missing; refresh membership before resuming") + return parent + + +async def attest_scope( + db: AsyncSession, + fence: WriterFence, + state: CaptureCycle, + scope: CompletedScope, + *, + require_owner_receipt: bool = True, +) -> bool: + """A scope's full owner chain must be visible and freshly enumerated.""" + parent = await scope_owner(db, fence, state, scope) + if parent is None: + return True + if ( + await db.scalar(select(Entity.id).where(Entity.id == parent.id, content_is_available())) + is None + ): + raise CycleConflict("Child scope no longer has a visible parent") + ready = await db.scalar( + select(Entity.id).where( + Entity.id == parent.id, content_is_available(), work_membership_ready(fence, state) + ) + ) + if ready is None or not await root_ready(db, fence, state): + raise CycleConflict("Refresh and complete ancestor membership before child work") + if ( + require_owner_receipt + and parent.entity_definition_short_name in state.configuration.exact_parent_validation + ): + verified = await db.scalar( + select(CaptureScan.id).where( + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.parent_record_id == parent.id, + CaptureScan.record_type == scope.record_type, + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.parent_visibility_epoch == parent.visibility_epoch, + CaptureScan.parent_verified_attempt_id == fence.attempt_id, + CaptureScan.parent_verified_revision == parent.record_revision, + ) + ) + if verified is None: + raise CycleConflict("Scope requires fresh exact parent verification") + return False + + +def child_scope_complete(fence: WriterFence, state: CaptureCycle, record_type: str): + """A completed child scope belongs to this exact owner generation.""" + predicates = [ + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.record_type == record_type, + CaptureScan.parent_record_id == Entity.id, + CaptureScan.parent_visibility_epoch == Entity.visibility_epoch, + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.phase == "complete", + ] + if state.configuration.fresh_inventory(record_type): + predicates.append(CaptureScan.membership_attempt_id == fence.attempt_id) + predicates.append( + or_( + Entity.entity_definition_short_name.not_in(state.configuration.exact_parent_validation), + and_( + CaptureScan.parent_verified_attempt_id == fence.attempt_id, + CaptureScan.parent_verified_revision == Entity.record_revision, + ), + ) + ) + return exists(select(CaptureScan.id).where(*predicates)) + + +async def next_scope_work( + db: AsyncSession, fence: WriterFence, cycle_id: UUID, *, within: CompletedScope | None = None +) -> ScopeWork | None: + """Reevaluate SQL frontier each time; newly discovered earlier UUIDs cannot be skipped.""" + _, state = await attest_cycle(db, fence, cycle_id) + for kind in () if within is not None else state.configuration.root_record_types: + predicates = [ + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + CaptureScan.record_type == kind, + CaptureScan.container_id.is_(None), + CaptureScan.parent_record_id.is_(None), + CaptureScan.cycle_id == cycle_id, + CaptureScan.phase == "complete", + ] + if state.configuration.fresh_inventory(kind): + predicates.append(CaptureScan.membership_attempt_id == fence.attempt_id) + if await db.scalar(select(CaptureScan.id).where(*predicates).limit(1)) is None: + return ScopeWork(record_type=kind) + selection = [] + if within is not None: + await attest_scope(db, fence, state, within) + owner = await scope_owner(db, fence, state, within) + identity = source_record(owner).identity if owner is not None else None + selection = [ + Entity.entity_definition_short_name == within.record_type, + Entity.container_id.is_not_distinct_from(within.container_id), + Entity.parent_record_type.is_not_distinct_from( + identity.record_type if identity else None + ), + Entity.parent_native_id.is_not_distinct_from(identity.native_id if identity else None), + Entity.parent_container_id.is_not_distinct_from( + identity.container_id if identity else None + ), + ] + for child_type, allowed in state.configuration.parents.items(): + parent_types = tuple(kind for kind in allowed if kind is not None) + if not parent_types: + continue + parent = await db.scalar( + select(Entity) + .where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name.in_(parent_types), + content_is_available(), + work_membership_ready(fence, state), + *selection, + ~child_scope_complete(fence, state, child_type), + ) + .order_by(Entity.id) + .limit(1) + .execution_options(populate_existing=True) + ) + if parent is not None: + return ScopeWork( + record_type=child_type, + parent=source_record(parent), + parent_visibility_epoch=parent.visibility_epoch, + ) + return None + + +async def terminal_checkpoint( + db: AsyncSession, state: CaptureCycle, request: CompleteCycle, now: datetime +) -> PromotedCheckpoint | None: + """Attest the source's terminal scan before publishing its provider boundary.""" + terminal = request.terminal_checkpoint + if state.mode == "mixed": + if terminal is not None: + raise CycleConflict("Mixed capture has no global provider checkpoint") + return None + if (state.starting_checkpoint is not None or state.mode == "changes") and terminal is None: + raise CycleConflict("Checkpoint-bearing capture requires its terminal scan boundary") + promoted = state.promoted_checkpoint if state.mode == "changes" else None + if terminal is not None: + if terminal.record_type not in state.configuration.root_record_types: + raise CycleConflict("Terminal checkpoint must belong to a declared root scope") + scan = await db.scalar( + select(CaptureScan).where( + CaptureScan.organization_id == request.fence.organization_id, + CaptureScan.sync_id == request.fence.sync_id, + CaptureScan.cycle_id == state.version.cycle_id, + CaptureScan.record_type == terminal.record_type, + CaptureScan.container_id.is_(None), + CaptureScan.parent_record_id.is_(None), + CaptureScan.phase == "complete", + CaptureScan.sweep_id == terminal.expected.sweep_id, + CaptureScan.revision == terminal.expected.revision, + ) + ) + if scan is None: + raise CycleConflict("Terminal scan changed or is not complete") + committed = scan.continuation.get(TERMINAL_CHECKPOINT_KEY) + if committed is None or ProviderCheckpoint.model_validate(committed) != terminal.checkpoint: + raise CycleConflict("Provider checkpoint does not match committed terminal page") + promoted = PromotedCheckpoint( + checkpoint=terminal.checkpoint, + configuration_digest=state.configuration.digest(), + promoted_at=now, + ) + return promoted + + +async def complete_cycle(db: AsyncSession, sync: Sync, request: CompleteCycle) -> CaptureCycle: + """Publish only when every parent eligible under the declared membership is complete.""" + cursor, state = await attest_cycle(db, request.fence, request.expected.cycle_id) + if state.version != request.expected: + raise CycleConflict("Cycle changed before completion") + if not await root_ready(db, request.fence, state): + raise CycleConflict("Root enumeration is incomplete or stale") + for child_type, parents in state.configuration.parents.items(): + parent_types = tuple(kind for kind in parents if kind is not None) + if not parent_types: + continue + missing = await db.scalar( + select(Entity.id) + .where( + Entity.organization_id == request.fence.organization_id, + Entity.sync_id == request.fence.sync_id, + Entity.entity_definition_short_name.in_(parent_types), + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + content_is_available(), + # The same current-sweep parent/ancestor predicate governs admission + # and frontier selection. Retained mode preserves provider semantics. + *( + (membership_ready(request.fence, state),) + if state.configuration.membership == "observed" + else () + ), + ~child_scope_complete(request.fence, state, child_type), + ) + .limit(1) + ) + if missing is not None: + raise CycleConflict("A visible parent still has an incomplete child scope") + now = datetime.now(timezone.utc) + promoted = await terminal_checkpoint(db, state, request, now) + evidence = state.last_full_capture + if state.mode == "full": + evidence = FullCaptureEvidence( + cycle_id=state.version.cycle_id, + completed_at=now, + configuration_digest=state.configuration.digest(), + discovery="scope_enumeration_complete" + if state.configuration.membership == "retained" + and all( + state.configuration.policy(kind) == "exhaustive" + for kind in state.configuration.parents + ) + else "incomplete", + ) + state = state.model_copy( + update={ + "phase": "complete", + "promoted_checkpoint": promoted, + "last_full_capture": evidence, + "completed_job_id": request.fence.job_id, + "version": CycleVersion( + cycle_id=state.version.cycle_id, revision=state.version.revision + 1 + ), + } + ) + persist(cursor, state) + cursor.cursor_data = { + **cursor.cursor_data, + "canonical_checkpoint": CanonicalCheckpoint( + writer_attempt_id=request.fence.attempt_id, + observed_change_sequence=sync.observed_change_sequence, + ).model_dump(mode="json"), + } + await db.flush() + return state + + +async def restart_cycle(db: AsyncSession, request: RestartCycle) -> CaptureCycle: + """Retain captured records/checkpoint; invalidate old scans with a fresh cycle UUID.""" + cursor, previous = await attest_cycle(db, request.fence, request.expected.cycle_id) + if previous.version != request.expected: + raise CycleConflict("Cycle changed before explicit restart") + state = next_cycle(previous, request) + persist(cursor, state) + await db.flush() + return state diff --git a/backend/airweave/domains/entities/canonical/extraction_models.py b/backend/airweave/domains/entities/canonical/extraction_models.py new file mode 100644 index 0000000000..cda9502fe2 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/extraction_models.py @@ -0,0 +1,86 @@ +"""Generation-owned extraction evidence, separate from captured original completeness.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator + + +class CharsetRecovery(BaseModel): + """A disclosed strict UTF-8 recovery after the attempted charset rejected bytes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + source_path: str = Field(min_length=1, max_length=2048) + from_charset: str = Field(min_length=1, max_length=256) + to_charset: Literal["utf-8"] = "utf-8" + + +class ExtractionPart(BaseModel): + """Stable source-local identity and format; no private content or download URLs.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + part_index: int = Field(ge=0) + key: str = Field(min_length=1, max_length=2048) + kind: Literal["body", "file", "record"] + media_type: str | None = Field(default=None, max_length=256) + extension: str | None = Field(default=None, max_length=32) + charset_recoveries: tuple[CharsetRecovery, ...] = () + + +class ExtractionOutcome(ExtractionPart): + """Indexed means every required chunk for this part was successfully published.""" + + outcome: Literal["indexed", "unsupported", "unavailable_original"] + reason: Literal["unsupported_format", "original_not_captured"] | None = None + + @model_validator(mode="after") + def reason_matches(self): + """Reject contradictory or free-text explanations.""" + expected = { + "indexed": None, + "unsupported": "unsupported_format", + "unavailable_original": "original_not_captured", + } + if self.reason != expected[self.outcome]: + raise ValueError("Extraction outcome requires its exact bounded reason") + return self + + +class ExtractionCoverage(BaseModel): + """Complete accounting of expected parts in one immutable publication generation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + parts: tuple[ExtractionOutcome, ...] + + @model_validator(mode="before") + @classmethod + def validate_serialized_status(cls, value: Any) -> Any: + """Accept wire status only as a checked attestation of the underlying parts.""" + if not isinstance(value, dict) or "status" not in value: + return value + facts = {key: item for key, item in value.items() if key != "status"} + coverage = cls.model_validate(facts) + if value["status"] != coverage.status: + raise ValueError("Serialized extraction status conflicts with its parts") + return facts + + @model_validator(mode="after") + def unique_parts(self): + """Each stable expected part appears once, with no ordinal gaps.""" + if len({p.key for p in self.parts}) != len(self.parts): + raise ValueError("Extraction part keys must be unique") + if tuple(p.part_index for p in self.parts) != tuple(range(len(self.parts))): + raise ValueError("Extraction must account for every expected part exactly once") + return self + + @computed_field + @property + def status(self) -> Literal["complete", "partial", "unavailable", "excluded"]: + """Empty coverage is reserved for intentional exclusions/tombstones.""" + if not self.parts: + return "excluded" + indexed = sum(p.outcome == "indexed" for p in self.parts) + return "complete" if indexed == len(self.parts) else "partial" if indexed else "unavailable" + + def persisted(self) -> dict: + """Computed status remains queryable but validates as derived, not trusted input.""" + return self.model_dump(mode="json", exclude={"status"}) diff --git a/backend/airweave/domains/entities/canonical/github_projection.py b/backend/airweave/domains/entities/canonical/github_projection.py new file mode 100644 index 0000000000..5581b81518 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/github_projection.py @@ -0,0 +1,146 @@ +"""Offline GitHub views; search never fetches uncaptured provider content.""" + +from pathlib import Path +from urllib.parse import quote + +from pydantic import BaseModel, ConfigDict, JsonValue + +from airweave.domains.entities.canonical.blob_materializer import read_blob, write_blob +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.entities._base import BaseEntity +from airweave.platform.entities.github import GitHubCodeFileEntity, GitHubTextEntity +from airweave.platform.sources.github_models import ( + BranchPayload, + IssuePayload, + Repository, + RepositoryContext, + TreeEntry, +) +from airweave.platform.utils.file_extensions import get_language_for_extension + + +class _Text(BaseModel): + model_config = ConfigDict(extra="ignore") + title: str | None = None + name: str | None = None + body: str | None = None + description: str | None = None + patch: str | None = None + filename: str | None = None + state: str | None = None + + +class _Child(BaseModel): + model_config = ConfigDict(extra="forbid") + native: dict[str, JsonValue] + repository: RepositoryContext + issue_number: int + head_sha: str | None = None + base_sha: str | None = None + + +class _File(BaseModel): + model_config = ConfigDict(extra="forbid") + entry: TreeEntry + path: str + ref: str + commit_sha: str + tree_sha: str + repository: RepositoryContext + + +async def map_github( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> tuple[BaseEntity, ...]: + """Map only current captured originals and verified immutable bytes.""" + kind = record.identity.record_type + if kind == "file": + return await _file(record, storage, directory) + if kind == "repository": + repository = Repository.model_validate(record.payload) + if str(repository.id) != record.identity.native_id: + raise ValueError("GitHub repository identity differs from captured payload") + data, full_name = record.payload, repository.full_name + elif kind == "branch": + branch = BranchPayload.model_validate(record.payload) + data, full_name = branch.branch, branch.repository.full_name + elif kind == "issue": + issue = IssuePayload.model_validate(record.payload) + data, full_name = issue.issue, issue.repository.full_name + elif kind in {"comment", "review", "review_comment", "pull_file"}: + child = _Child.model_validate(record.payload) + data, full_name = child.native, child.repository.full_name + else: + raise ValueError("Unsupported GitHub original kind") + native = _Text.model_validate(data) + return ( + GitHubTextEntity( + original_id=str(record.id), + title=native.title + or native.name + or native.filename + or f"{kind} {record.identity.native_id}", + resource_kind=kind, + repository=full_name, + text=native.body or native.description or native.patch or "", + state=native.state, + content_coverage=record.completeness, + breadcrumbs=[], + created_at=record.source_created_at, + updated_at=record.source_updated_at, + ), + ) + + +async def _file( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> tuple[BaseEntity, ...]: + data = _File.model_validate(record.payload) + if data.path != record.identity.native_id: + raise ValueError("GitHub file path differs from captured identity") + if record.completeness != "complete": + return ( + GitHubTextEntity( + original_id=str(record.id), + title=data.path, + resource_kind="file", + repository=data.repository.full_name, + text="", + content_coverage=record.completeness, + breadcrumbs=[], + ), + ) + if len(record.blobs) != 1 or data.entry.type != "blob": + raise ValueError("Complete GitHub file requires one retained blob") + content = await read_blob(record, record.blobs[0], storage) + suffix = Path(data.path).suffix.lower() + if not suffix: + content.decode("utf-8") # Extensionless text is supported; arbitrary binary is not. + suffix = ".txt" + local = await write_blob(content, directory, suffix=suffix) + owner, repo = data.repository.full_name.split("/", 1) + url = ( + f"https://github.com/{data.repository.full_name}/blob/{data.commit_sha}/" + f"{quote(data.path, safe='/')}" + ) + return ( + GitHubCodeFileEntity( + full_path=data.path, + name=Path(data.path).name, + branch=data.ref, + sha=data.entry.sha, + repo_name=repo, + repo_owner=owner, + path_in_repo=data.path, + language=get_language_for_extension(suffix), + commit_id=data.commit_sha, + url=url, + html_url=url, + size=len(content), + file_type=suffix, + mime_type=record.blobs[0].media_type, + local_path=str(local), + breadcrumbs=[], + ), + ) diff --git a/backend/airweave/domains/entities/canonical/gmail_projection.py b/backend/airweave/domains/entities/canonical/gmail_projection.py new file mode 100644 index 0000000000..1e91097e08 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/gmail_projection.py @@ -0,0 +1,219 @@ +"""Deterministic Gmail search inputs from committed payloads and immutable blobs only.""" + +import base64 +import hashlib +import html +import mimetypes +from datetime import datetime, timezone +from email.message import Message +from email.utils import formataddr, getaddresses +from pathlib import Path + +from airweave.domains.entities.canonical.blob_materializer import read_blob, write_blob +from airweave.domains.entities.canonical.extraction_models import CharsetRecovery, ExtractionPart +from airweave.domains.entities.canonical.mime_text import DecodedText, decode_mime_text +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.entities._base import Breadcrumb +from airweave.platform.entities.gmail import GmailAttachmentEntity, GmailMessageEntity + + +def _header(part: dict, name: str) -> str: + return next( + (h["value"] for h in part.get("headers", []) if h["name"].lower() == name.lower()), "" + ) + + +def _attachment(part: dict) -> bool: + mime = part.get("mimeType", "").lower() + return ( + bool(part.get("filename")) + or _header(part, "Content-Disposition").lower().startswith("attachment") + or (not mime.startswith("multipart/") and mime not in {"text/plain", "text/html"}) + ) + + +async def _body( + part: dict, + path: str, + record: SourceRecord, + storage: StorageBackend, +) -> bytes: + body = part.get("body", {}) + if body.get("attachmentId"): + matching = [b for b in record.blobs if b.source_path == path + "/body"] + if len(matching) != 1: + raise ValueError("Gmail MIME body requires exactly one stored blob reference") + content = await read_blob(record, matching[0], storage) + else: + encoded = body.get("data", "") + content = base64.b64decode( + encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True + ) + # Native size is retained metadata, not our integrity authority: live Gmail + # responses can disagree with their decoded data. External bytes have already + # passed canonical size/SHA verification; inline bytes came from retained JSON. + return content + + +def _decode_text(content: bytes, part: dict, path: str) -> DecodedText: + header = Message() + header["Content-Type"] = _header(part, "Content-Type") or part["mimeType"] + return decode_mime_text(content, header.get_content_charset() or "utf-8", path) + + +class GmailProjection: + """One message projection; per-call state never escapes into the source record.""" + + def __init__(self, record: SourceRecord, storage: StorageBackend, directory: Path): + """Bind committed source authority and disposable output directory.""" + self.record = record + self.storage = storage + self.directory = directory + self.attachments: list[ProjectionInput] = [] + self.charset_recoveries: list[CharsetRecovery] = [] + self.decoded_text: dict[str, DecodedText] = {} + + async def _decoded_text_for(self, part: dict, path: str) -> DecodedText: + """Decode one MIME body once, whether selection or rendering reaches it first.""" + decoded = self.decoded_text.get(path) + if decoded is None: + content = await _body(part, path, self.record, self.storage) + decoded = _decode_text(content, part, path) + self.decoded_text[path] = decoded + return decoded + + async def _has_nonempty_html(self, part: dict, path: str) -> bool: + """Check decoded HTML content, including HTML nested in related parts.""" + mime = part.get("mimeType", "").lower() + if mime.startswith("multipart/"): + children = list(enumerate(part.get("parts", []))) + if mime == "multipart/alternative": + children.reverse() + for index, child in children: + if await self._has_nonempty_html(child, f"{path}/parts/{index}"): + return True + return False + if mime != "text/html" or _attachment(part): + return False + decoded = await self._decoded_text_for(part, path) + return bool(decoded.text.strip()) + + async def attachment(self, part: dict, path: str) -> None: + """Project retained attachment bytes; keep missing bytes explicit on the original.""" + filename = part.get("filename") or "attachment" + mime = part.get("mimeType", "application/octet-stream") + suffix = Path(filename).suffix.lower() or mimetypes.guess_extension(mime) or ".bin" + descriptor = ExtractionPart( + part_index=len(self.attachments) + 1, + key=path, + kind="file", + media_type=mime, + extension=suffix, + ) + if ( + self.record.completeness == "partial" + and part.get("body", {}).get("attachmentId") + and not any(blob.source_path == path + "/body" for blob in self.record.blobs) + ): + self.attachments.append(ProjectionInput(part=descriptor, entity=None)) + return + content = await _body(part, path, self.record, self.storage) + local = await write_blob(content, self.directory, suffix=suffix) + message_id = self.record.identity.native_id + entity = GmailAttachmentEntity( + breadcrumbs=[ + Breadcrumb( + entity_id=f"msg_{message_id}", + name="Email message", + entity_type="GmailMessageEntity", + ) + ], + attachment_key=f"attach_{message_id}_{hashlib.sha256(path.encode()).hexdigest()}", + filename=filename, + message_id=message_id, + attachment_id=part.get("body", {}).get("attachmentId") or part.get("partId") or path, + thread_id=self.record.payload["threadId"], + url=f"https://mail.google.com/mail/u/0/#inbox/{message_id}", + size=len(content), + file_type=suffix.lstrip("."), + mime_type=mime, + local_path=str(local), + ) + self.attachments.append(ProjectionInput(part=descriptor, entity=entity)) + + async def render(self, part: dict, path: str) -> str: + """Select MIME alternatives, preserve mixed bodies, and materialize attachments.""" + mime = part.get("mimeType", "").lower() + if mime.startswith("multipart/"): + children = list(enumerate(part.get("parts", []))) + if mime == "multipart/alternative": + html_part = None + for index, child in reversed(children): + if await self._has_nonempty_html(child, f"{path}/parts/{index}"): + html_part = (index, child) + break + plain_parts = [(i, p) for i, p in children if p.get("mimeType") == "text/plain"] + # Prefer one complete alternative, not duplicate plain and HTML text. + children = [html_part] if html_part else plain_parts[-1:] or children[-1:] + return "\n".join( + [await self.render(child, f"{path}/parts/{index}") for index, child in children] + ) + if _attachment(part): + await self.attachment(part, path) + return "" + decoded = await self._decoded_text_for(part, path) + if decoded.recovery is not None: + self.charset_recoveries.append(decoded.recovery) + text = decoded.text + return text if mime == "text/html" else "
" + html.escape(text) + "
" + + async def map(self) -> ProjectionInputs: + """Produce file entities for strict conversion without any provider network calls.""" + data = self.record.payload + if self.record.deleted_at is not None or self.record.completeness not in { + "complete", + "partial", + }: + raise ValueError("Gmail projection requires an active captured source record") + if data.get("id") != self.record.identity.native_id or not data.get("threadId"): + raise ValueError("Gmail projection identity is inconsistent") + body = await self.render(data["payload"], "/payload") + local = await write_blob(body.encode("utf-8"), self.directory, suffix=".html") + entity = GmailMessageEntity.from_api(data, thread_id=data["threadId"], breadcrumbs=[]) + entity.local_path = str(local) + entity.size = len(body.encode("utf-8")) + internal = datetime.fromtimestamp(int(data["internalDate"]) / 1000, timezone.utc) + entity.internal_date = internal + entity.internal_timestamp = internal + if entity.sent_at.tzinfo is None: + entity.sent_at = entity.sent_at.replace(tzinfo=timezone.utc) + for field in ("to", "cc", "bcc"): + addresses = getaddresses([_header(data["payload"], field)]) + setattr(entity, field, [formataddr(address) for address in addresses if address[1]]) + return ProjectionInputs( + parts=( + ProjectionInput( + part=ExtractionPart( + part_index=0, + key="/payload/body", + kind="body", + media_type="text/html", + extension=".html", + charset_recoveries=tuple(self.charset_recoveries), + ), + entity=entity, + ), + *self.attachments, + ) + ) + + +async def map_gmail( + record: SourceRecord, + storage: StorageBackend, + directory: Path, +) -> ProjectionInputs: + """Map immutable Gmail capture into existing message and attachment entities.""" + return await GmailProjection(record, storage, directory).map() diff --git a/backend/airweave/domains/entities/canonical/linear_projection.py b/backend/airweave/domains/entities/canonical/linear_projection.py new file mode 100644 index 0000000000..374729606a --- /dev/null +++ b/backend/airweave/domains/entities/canonical/linear_projection.py @@ -0,0 +1,152 @@ +"""Offline Linear views of native originals and verified immutable files.""" + +import mimetypes +from pathlib import Path +from urllib.parse import urlsplit +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + +from airweave.domains.entities.canonical.blob_materializer import read_blob, write_blob +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.entities._base import BaseEntity, Breadcrumb +from airweave.platform.entities.linear import ( + LinearAttachmentEntity, + LinearCommentEntity, + LinearIssueEntity, + LinearLinkedAttachmentEntity, +) + + +class _Named(BaseModel): + model_config = ConfigDict(extra="ignore") + id: UUID + name: str + + +class _IssueContext(BaseModel): + model_config = ConfigDict(extra="ignore") + id: UUID + identifier: str = Field(min_length=1) + title: str + url: str + team: _Named + project: _Named | None = None + + +class _Original(BaseModel): + model_config = ConfigDict(extra="ignore") + id: UUID + createdAt: AwareDatetime + updatedAt: AwareDatetime + url: str + + +class _Child(_Original): + issue: _IssueContext + + +class _Comment(_Child): + body: str + + +class _Attachment(_Child): + title: str + + +def _breadcrumbs(issue: _IssueContext) -> list[Breadcrumb]: + return [ + Breadcrumb(entity_id=str(issue.id), name=issue.identifier, entity_type="LinearIssueEntity") + ] + + +async def map_linear( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> tuple[BaseEntity, ...]: + """Map only retained observations, rejecting identity drift or missing native context.""" + original = _Original.model_validate(record.payload) + if str(original.id) != record.identity.native_id: + raise ValueError("Linear record identity differs from captured original") + kind = record.identity.record_type + if kind == "issue": + _IssueContext.model_validate(record.payload) + if record.identity.container_id is not None: + raise ValueError("Linear issue unexpectedly has a parent container") + issue = LinearIssueEntity.from_api(record.payload) + issue.web_url_value = original.url + return (issue,) + if kind not in {"comment", "attachment"}: + raise ValueError("Unsupported Linear original kind") + child = _Child.model_validate(record.payload) + context = child.issue + if str(context.id) != record.identity.container_id: + raise ValueError("Linear child parent differs from canonical container") + breadcrumbs = _breadcrumbs(context) + if kind == "comment": + _Comment.model_validate(record.payload) + entity = LinearCommentEntity.from_api( + record.payload, + issue_id=str(context.id), + issue_identifier=context.identifier, + breadcrumbs=breadcrumbs, + team_id=str(context.team.id), + team_name=context.team.name, + project_id=str(context.project.id) if context.project else None, + project_name=context.project.name if context.project else None, + ) + entity.web_url_value = original.url + return (entity,) + return await _attachment(record, storage, directory) + + +async def _attachment( + record: SourceRecord, + storage: StorageBackend, + directory: Path, +) -> tuple[BaseEntity, ...]: + attachment = _Attachment.model_validate(record.payload) + context = attachment.issue + breadcrumbs = _breadcrumbs(context) + if not record.blobs: + if record.completeness == "complete": + raise ValueError("Linear attachment claims complete content without retained bytes") + return ( + LinearLinkedAttachmentEntity( + attachment_id=str(attachment.id), + title=attachment.title, + issue_identifier=context.identifier, + target_url=attachment.url, + content_coverage="Link metadata only; file content is not retained", + breadcrumbs=breadcrumbs, + created_at=attachment.createdAt, + updated_at=attachment.updatedAt, + ), + ) + if len(record.blobs) != 1 or record.blobs[0].source_path != "/url": + raise ValueError("Linear attachment requires one exact URL blob reference") + ref = record.blobs[0] + content = await read_blob(record, ref, storage) + suffix = mimetypes.guess_extension(ref.media_type or "") + if not suffix: + suffix = Path(urlsplit(attachment.url).path).suffix.lower() + if not suffix: + raise ValueError("Retained Linear file has no known format for projection") + local_path = await write_blob(content, directory, suffix=suffix) + return ( + LinearAttachmentEntity( + attachment_id=str(attachment.id), + issue_id=str(context.id), + issue_identifier=context.identifier, + title=attachment.title, + url=attachment.url, + size=len(content), + file_type=suffix.lstrip("."), + mime_type=ref.media_type, + local_path=str(local_path), + breadcrumbs=breadcrumbs, + web_url_value=attachment.url, + created_at=attachment.createdAt, + updated_at=attachment.updatedAt, + ), + ) diff --git a/backend/airweave/domains/entities/canonical/mime_text.py b/backend/airweave/domains/entities/canonical/mime_text.py new file mode 100644 index 0000000000..c7163f277b --- /dev/null +++ b/backend/airweave/domains/entities/canonical/mime_text.py @@ -0,0 +1,29 @@ +"""Strict MIME text decoding with explicit, bounded recovery evidence.""" + +from pydantic import BaseModel, ConfigDict + +from airweave.domains.entities.canonical.extraction_models import CharsetRecovery + + +class DecodedText(BaseModel): + """Derived text and any charset correction; original bytes remain authoritative.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + text: str + recovery: CharsetRecovery | None = None + + +def decode_mime_text(content: bytes, charset: str, source_path: str) -> DecodedText: + """Honor the charset first; recover only bytes that are strictly valid UTF-8. + + Unknown codecs and content invalid in both encodings still fail. No detector, + replacement characters or discarded bytes can turn a failure into success. + """ + try: + return DecodedText(text=content.decode(charset, errors="strict")) + except UnicodeDecodeError: + text = content.decode("utf-8", errors="strict") + return DecodedText( + text=text, + recovery=CharsetRecovery(source_path=source_path, from_charset=charset), + ) diff --git a/backend/airweave/domains/entities/canonical/models.py b/backend/airweave/domains/entities/canonical/models.py new file mode 100644 index 0000000000..1c0bb36f68 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/models.py @@ -0,0 +1,90 @@ +"""Canonical record results. Journal snapshots are historical, reads are current.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, JsonValue + +from airweave.domains.entities.canonical.coverage_models import CaptureCoverage +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity + + +class SourceRecord(BaseModel): + """Durable source state, independent of search projection success.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + sync_id: UUID + identity: RecordIdentity + parent: RecordIdentity | None = None + revision: int + payload: dict[str, JsonValue] + content_access: Literal["available", "unavailable"] = "available" + payload_schema_version: int + capture_hash: str + content_hash: str | None + completeness: str + observed_at: datetime + source_created_at: datetime | None + source_updated_at: datetime | None + deleted_at: datetime | None + removal_reason: str | None + blobs: tuple[BlobReference, ...] + indexed_revision: int | None + indexed_pipeline_version: int | None + + +class IndexedRecordRead(SourceRecord): + """Current original plus independently attested current extraction coverage.""" + + extraction: ExtractionCoverage | None = None + + +class ObservedChange(BaseModel): + """Snapshot at commit time; never a claim to every upstream transient event.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + sequence: int + kind: Literal["upsert", "delete"] + record: SourceRecord + + +class CaptureResult(BaseModel): + """Journal events created by this commit and its final per-sync position.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + changes: tuple[ObservedChange, ...] + sequence: int + unchanged: int + + +class ReconcileResult(BaseModel): + """Repeat while more remains; only then may the source commit its checkpoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + capture: CaptureResult + has_more: bool + + +class ChangePage(BaseModel): + """All committed changes after a position, capped at a fixed high watermark.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + changes: tuple[ObservedChange, ...] + next_sequence: int + high_watermark: int + has_more: bool + + +class RecordPage(BaseModel): + """Committed records with truthful traversal semantics.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + capture: CaptureCoverage | None = None + records: tuple[SourceRecord, ...] + next_cursor: str | None + has_more: bool + consistency: Literal["live"] = "live" + order: Literal["id_asc"] = "id_asc" diff --git a/backend/airweave/domains/entities/canonical/notion_projection.py b/backend/airweave/domains/entities/canonical/notion_projection.py new file mode 100644 index 0000000000..9d1e6cce65 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/notion_projection.py @@ -0,0 +1,258 @@ +"""Offline, record-local Notion search text; no page assembly or provider access.""" + +from typing import Literal +from uuid import UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + HttpUrl, + JsonValue, + StrictBool, + TypeAdapter, + field_validator, +) + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import ProjectionMappingError +from airweave.platform.entities.notion import NotionOriginalEntity + + +class _Native(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class _RichText(_Native): + type: Literal["text", "mention", "equation"] + plain_text: str + + +class _Original(_Native): + id: UUID + object: Literal["page", "database", "data_source", "block"] + created_time: AwareDatetime + last_edited_time: AwareDatetime + in_trash: StrictBool + url: str | None = None + + @field_validator("url") + @classmethod + def native_url(cls, value: str | None) -> str | None: + if value is not None: + parsed = TypeAdapter(HttpUrl).validate_python(value) + if ( + parsed.scheme != "https" + or parsed.host not in {"app.notion.com", "notion.so", "www.notion.so"} + or parsed.username is not None + or parsed.password is not None + or parsed.port not in (None, 443) + ): + raise ProjectionMappingError("Notion original URL is outside audited native hosts") + return value + + +class _Property(_Native): + type: str + title: list[_RichText] | None = None + + +class _Page(_Original): + properties: dict[str, _Property] + + +class _Database(_Original): + title: list[_RichText] + description: list[_RichText] + + +class _SchemaProperty(_Native): + type: str + + +class _DataSource(_Database): + properties: dict[str, _SchemaProperty] + + +class _Parent(_Native): + type: Literal["page_id", "block_id"] + page_id: UUID | None = None + block_id: UUID | None = None + + +class _Block(_Original): + type: str + parent: _Parent + has_children: StrictBool + + +class _Text(_Native): + rich_text: list[_RichText] + + +class _Code(_Text): + language: str + caption: list[_RichText] + + +class _Todo(_Text): + checked: StrictBool + + +class _TableRow(_Native): + cells: list[list[_RichText]] + + +class _Reference(_Native): + title: str + + +class _Equation(_Native): + expression: str + + +class _Table(_Native): + table_width: int + has_column_header: StrictBool + has_row_header: StrictBool + + +class _Synced(_Native): + synced_from: dict[str, str] | None + + +def _text(items: list[_RichText]) -> str: + # Native fragments include their own spacing. Mentions use only retained labels. + return "".join(item.plain_text for item in items) + + +def _block_text(record: SourceRecord, block: _Block) -> tuple[str, str, str]: + parent = record.parent + native_parent = ( + block.parent.page_id if block.parent.type == "page_id" else block.parent.block_id + ) + expected_kind = "page" if block.parent.type == "page_id" else "block" + if ( + parent is None + or parent.record_type != expected_kind + or native_parent is None + or parent.native_id != str(native_parent) + or parent.container_id is not None + ): + raise ProjectionMappingError("Notion block parent differs from captured authority") + kind = block.type + content = record.payload.get(kind) + coverage = "This block only; child blocks, comments and file contents are not included." + if kind in { + "paragraph", + "heading_1", + "heading_2", + "heading_3", + "bulleted_list_item", + "numbered_list_item", + "quote", + "toggle", + "callout", + }: + text = _text(_Text.model_validate(content).rich_text) + elif kind == "to_do": + todo = _Todo.model_validate(content) + text = ("[x] " if todo.checked else "[ ] ") + _text(todo.rich_text) + elif kind == "code": + code = _Code.model_validate(content) + text = "\n".join( + part + for part in ( + code.language, + _text(code.rich_text), + _text(code.caption), + ) + if part + ) + elif kind == "table_row": + text = " | ".join(_text(cell) for cell in _TableRow.model_validate(content).cells) + elif kind == "equation": + text = _Equation.model_validate(content).expression + else: + return _structure(kind, content) + title = text.splitlines()[0][:160] if text else kind.replace("_", " ").capitalize() + return title, text, coverage + + +def _structure(kind: str, content: JsonValue) -> tuple[str, str, str]: + """Render known structure/reference labels without inventing their body.""" + if kind in {"child_page", "child_database"}: + label = _Reference.model_validate(content).title + return ( + label or "Untitled reference", + "", + "Reference label only; linked content is separate.", + ) + elif kind == "synced_block": + synced = _Synced.model_validate(content) + if synced.synced_from is not None: + raise ProjectionMappingError( + "Synced block reference content is not independently retained" + ) + return "Synced block", "", "Container only; child blocks are separate." + elif kind == "table": + _Table.model_validate(content) + return "Table", "", "Table container only; rows are separate." + elif kind in {"column", "column_list", "divider", "table_of_contents"}: + _Native.model_validate(content) + return kind.replace("_", " ").capitalize(), "", "Structure only; no body text." + else: + raise ProjectionMappingError("Notion block type has no supported text projection") + + +def map_notion(record: SourceRecord) -> tuple[NotionOriginalEntity, ...]: + """Map one retained native observation; missing/unknown content stays pending.""" + native = _Original.model_validate(record.payload) + kind = record.identity.record_type + if ( + str(native.id) != record.identity.native_id + or native.object != kind + or record.identity.container_id is not None + ): + raise ProjectionMappingError("Notion original identity differs from captured identity") + if native.in_trash: + raise ProjectionMappingError("Trashed Notion content cannot be projected") + if kind == "block": + title, text, coverage = _block_text(record, _Block.model_validate(record.payload)) + else: + if record.parent is not None: + raise ProjectionMappingError("Notion root has an unexpected capture parent") + coverage = ( + "Metadata only; page body, full properties, comments " + "and file contents are not included." + ) + if kind == "page": + page = _Page.model_validate(record.payload) + titles = [prop.title for prop in page.properties.values() if prop.type == "title"] + if len(titles) != 1 or titles[0] is None: + raise ProjectionMappingError("Notion page requires its retained title property") + title, text = _text(titles[0]), "" + elif kind == "database": + database = _Database.model_validate(record.payload) + title, text = _text(database.title), _text(database.description) + elif kind == "data_source": + data = _DataSource.model_validate(record.payload) + title = _text(data.title) + schema = "\n".join(f"{name} ({prop.type})" for name, prop in data.properties.items()) + text = "\n".join(part for part in (_text(data.description), schema) if part) + else: + raise ProjectionMappingError("Unsupported Notion original kind") + title = title or "Untitled " + kind.replace("_", " ") + return ( + NotionOriginalEntity( + native_id=record.identity.native_id, + original_kind=kind, + title=title, + text=text, + native_url=native.url, + content_coverage=coverage, + breadcrumbs=[], + created_at=native.created_time, + updated_at=native.last_edited_time, + ), + ) diff --git a/backend/airweave/domains/entities/canonical/notion_property_projection.py b/backend/airweave/domains/entities/canonical/notion_property_projection.py new file mode 100644 index 0000000000..d545319beb --- /dev/null +++ b/backend/airweave/domains/entities/canonical/notion_property_projection.py @@ -0,0 +1,300 @@ +"""Offline rendering of complete, bounded native Notion property archives.""" + +from typing import Literal +from uuid import UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + FiniteFloat, + JsonValue, + StrictBool, + StrictInt, + TypeAdapter, +) + +from airweave.domains.entities.canonical.blob_materializer import read_blob +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.entities.canonical.projection_mappers import ProjectionMappingError +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.entities.notion import NotionOriginalEntity + +MAX_PROPERTY_PROJECTION_BYTES = 16 * 1024 * 1024 + + +class _Header(BaseModel): + model_config = ConfigDict(extra="forbid") + format_version: Literal[1] + page_id: UUID + property_id: str = Field(min_length=1) + notion_version: Literal["2026-03-11"] + page_last_edited_time: AwareDatetime + + +class _Archive(_Header): + responses: list[dict[str, JsonValue]] = Field(min_length=1) + + +class _Manifest(_Header): + name: str + property: dict[str, JsonValue] + response_count: int = Field(ge=1) + value_status: Literal["available", "unsupported"] + + +class _Native(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class _Value(_Native): + id: str + type: str + + +class _Item(_Value): + object: Literal["property_item"] + + +class _Status(_Native): + type: Literal["complete"] + + +class _Page(_Native): + object: Literal["list"] + type: Literal["property_item"] + results: list[dict[str, JsonValue]] + property_item: dict[str, JsonValue] + has_more: StrictBool + next_cursor: str | None + request_status: _Status | None = None + + +class _Label(_Native): + name: str + + +class _Reference(_Native): + id: UUID + name: str | None = None + + +class _Date(_Native): + start: str + end: str | None = None + time_zone: str | None = None + + +class _Rich(_Native): + type: Literal["text", "mention", "equation"] + plain_text: str + + +class _Unique(_Native): + number: StrictInt | None + prefix: str | None + + +class _Unsupported(Exception): + """Known native values whose searchable interpretation is not implemented.""" + + +_LIST_TYPES = {"title", "rich_text", "people", "relation", "rollup"} +_SUPPORTED = _LIST_TYPES | { + "select", + "status", + "multi_select", + "files", + "created_by", + "last_edited_by", + "date", + "checkbox", + "boolean", + "number", + "url", + "email", + "phone_number", + "string", + "created_time", + "last_edited_time", + "unique_id", + "formula", +} + + +def _known(kind: str) -> None: + if kind not in _SUPPORTED: + if kind in {"verification", "button", "place"}: + raise _Unsupported + raise ProjectionMappingError("Unknown Notion property value type") + + +def _render(kind: str, value: JsonValue) -> str: # noqa: C901 + _known(kind) + if value is None: + if kind in {"number", "date", "select", "status", "url", "email", "phone_number", "string"}: + return "" + raise ProjectionMappingError("Notion property has an invalid null value") + if kind in {"title", "rich_text"}: + return _Rich.model_validate(value).plain_text + if kind in {"select", "status"}: + return _Label.model_validate(value).name + if kind in {"multi_select", "files"}: + return ", ".join(item.name for item in TypeAdapter(list[_Label]).validate_python(value)) + if kind in {"people", "relation", "created_by", "last_edited_by"}: + ref = _Reference.model_validate(value) + return f"{ref.name} ({ref.id})" if ref.name else str(ref.id) + if kind == "date": + date = _Date.model_validate(value) + return ( + date.start + + (f" – {date.end}" if date.end else "") + + (f" ({date.time_zone})" if date.time_zone else "") + ) + if kind in {"checkbox", "boolean"}: + return "true" if TypeAdapter(StrictBool).validate_python(value) else "false" + if kind == "number": + return str(TypeAdapter(StrictInt | FiniteFloat).validate_python(value, strict=True)) + if kind in {"url", "email", "phone_number", "string", "created_time", "last_edited_time"}: + return TypeAdapter(str).validate_python(value, strict=True) + if kind == "unique_id": + unique = _Unique.model_validate(value) + return (f"{unique.prefix}-" if unique.prefix else "") + ( + str(unique.number) if unique.number is not None else "" + ) + if kind in {"formula", "rollup"}: + result = TypeAdapter(dict[str, JsonValue]).validate_python(value) + subtype = TypeAdapter(str).validate_python(result.get("type")) + if subtype == "incomplete": + raise ProjectionMappingError("Notion property calculation is incomplete") + if subtype == "unsupported": + raise _Unsupported + if subtype not in {"string", "number", "boolean", "date"}: + raise _Unsupported + if subtype not in result: + raise ProjectionMappingError("Notion calculation result is missing") + return _render(subtype, result[subtype]) + if kind in {"verification", "button", "place"}: + raise _Unsupported + raise ProjectionMappingError("Unknown Notion property value type") + + +def _pagination(page: _Page, last: bool, cursors: set[str]) -> None: + if page.has_more == last or (page.next_cursor is None) != last: + raise ProjectionMappingError("Notion property archive pagination is incomplete") + if page.next_cursor is not None: + if not page.next_cursor or page.next_cursor in cursors: + raise ProjectionMappingError("Notion property archive repeats a cursor") + cursors.add(page.next_cursor) + if last and page.property_item.get("next_url") is not None: + raise ProjectionMappingError("Notion terminal property retains continuation") + + +def _values( + archive: _Archive, prop: _Value +) -> tuple[list[dict[str, JsonValue]], dict[str, JsonValue]]: + values: list[dict[str, JsonValue]] = [] + metadata = {} + cursors: set[str] = set() + for index, raw in enumerate(archive.responses): + last = index == len(archive.responses) - 1 + if raw.get("object") == "property_item": + if len(archive.responses) != 1 or prop.type in _LIST_TYPES: + raise ProjectionMappingError("Notion single property has multiple responses") + item = _Item.model_validate(raw) + metadata = raw + values.append(raw) + else: + if prop.type not in _LIST_TYPES: + raise ProjectionMappingError("Notion scalar property returned a list") + page = _Page.model_validate(raw) + metadata = page.property_item + item = _Value.model_validate(metadata) + _pagination(page, last, cursors) + for raw_item in page.results: + parsed = _Item.model_validate(raw_item) + if prop.type != "rollup" and (parsed.id != prop.id or parsed.type != prop.type): + raise ProjectionMappingError("Notion property contains another identity") + values.extend(page.results) + if item.id != prop.id or item.type != prop.type: + raise ProjectionMappingError("Notion property metadata identity mismatch") + return values, metadata + + +def _text(archive: _Archive, prop: _Value, status: str) -> str: + _known(prop.type) + values, metadata = _values(archive, prop) + if prop.type in {"formula", "rollup"}: + calculation = TypeAdapter(dict[str, JsonValue]).validate_python(metadata.get(prop.type)) + subtype = calculation.get("type") + if (subtype == "unsupported") != (status == "unsupported"): + raise ProjectionMappingError("Notion property calculation status mismatch") + if prop.type == "rollup" and subtype == "array": + # show_original returns target values in results, not the metadata array. + return "\n".join( + _render(_Item.model_validate(v).type, v[_Item.model_validate(v).type]) + for v in values + ) + return _render(prop.type, calculation) + if status != "available": + raise ProjectionMappingError("Notion non-calculation property has invalid status") + rendered = [_render(prop.type, value[prop.type]) for value in values] + return ("" if prop.type in {"title", "rich_text"} else "\n").join(rendered) + + +async def map_notion_property(record: SourceRecord, storage: StorageBackend) -> ProjectionInputs: + """Validate retained authority and render values without fetching referenced objects.""" + manifest = _Manifest.model_validate(record.payload) + prop = _Value.model_validate(manifest.property) + parent = record.parent + if ( + record.payload_schema_version != 2 + or parent is None + or parent.record_type != "page" + or parent.container_id is not None + or parent.native_id != str(manifest.page_id) + or record.identity.container_id != str(manifest.page_id) + or record.identity.native_id != manifest.property_id + or prop.id != manifest.property_id + or len(record.blobs) != 1 + ): + raise ProjectionMappingError("Notion property archive authority mismatch") + blob = record.blobs[0] + if ( + blob.media_type != "application/json" + or blob.role is not None + or blob.source_path is not None + ): + raise ProjectionMappingError("Notion property archive descriptor mismatch") + if blob.size_bytes > MAX_PROPERTY_PROJECTION_BYTES: + raise ProjectionMappingError("Notion property exceeds the 16 MiB projection budget") + archive = _Archive.model_validate_json(await read_blob(record, blob, storage)) + header = tuple(_Header.model_fields) + if ( + archive.model_dump(include=set(header)) != manifest.model_dump(include=set(header)) + or len(archive.responses) != manifest.response_count + ): + raise ProjectionMappingError("Notion property archive provenance mismatch") + part = ExtractionPart(part_index=0, key=prop.id, kind="record", media_type="application/json") + try: + text = _text(archive, prop, manifest.value_status) + except _Unsupported: + return ProjectionInputs( + parts=(ProjectionInput(part=part, entity=None, omission="unsupported_format"),) + ) + entity = NotionOriginalEntity( + native_id=prop.id, + original_kind="page_property", + title=manifest.name, + text=text, + breadcrumbs=[], + native_url=None, + created_at=None, + updated_at=None, + content_coverage="This property only. References and file names exclude linked bodies and " + "file bytes. Related values are not a transactional snapshot.", + ) + return ProjectionInputs(parts=(ProjectionInput(part=part, entity=entity),)) diff --git a/backend/airweave/domains/entities/canonical/outlook_calendar_projection.py b/backend/airweave/domains/entities/canonical/outlook_calendar_projection.py new file mode 100644 index 0000000000..30b84b5597 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/outlook_calendar_projection.py @@ -0,0 +1,225 @@ +"""Offline projection of retained Graph calendar metadata and event bodies.""" + +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field + +from airweave.domains.entities.canonical.blob_materializer import write_blob +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.sync_pipeline.pipeline.text_models import NativeTextBody +from airweave.platform.entities.outlook_calendar import ( + OutlookCalendarHtmlEventEntity, + OutlookCalendarMetadataEntity, + OutlookCalendarSearchEventEntity, +) +from airweave.platform.sources.outlook_calendar_models import OutlookCalendar, OutlookCalendarEvent +from airweave.platform.sources.outlook_mail_models import OutlookAddress, OutlookRecipient + + +class _Response(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + response: str | None = None + + +class _Attendee(OutlookRecipient): + type: str | None = None + status: _Response | None = None + + +class _Pattern(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + type: str + interval: int | None = None + month: int | None = None + dayOfMonth: int | None = None + daysOfWeek: list[str] = Field(default_factory=list) + firstDayOfWeek: str | None = None + index: str | None = None + + +class _Range(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + type: str + startDate: str + endDate: str | None = None + recurrenceTimeZone: str | None = None + numberOfOccurrences: int | None = None + + +class _Recurrence(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + pattern: _Pattern + range: _Range + + +class _Location(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + displayName: str | None = None + + +class _Details(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + location: _Location | None = None + locations: list[_Location] = Field(default_factory=list) + categories: list[str] = Field(default_factory=list) + showAs: str | None = None + + +def _address(value: OutlookAddress) -> str | None: + return ( + " <".join([value.name, value.address]) + ">" + if value.name and value.address + else (value.name or value.address) + ) + + +def _attendee(value: _Attendee) -> str: + fields = [_address(value.emailAddress)] + if value.type is not None: + fields.append(f"type: {value.type}") + if value.status is not None and value.status.response is not None: + fields.append(f"response: {value.status.response}") + return "; ".join(field for field in fields if field) + + +def _recurrence(value: _Recurrence) -> str: + # Only declared native recurrence fields enter search, never extension JSON. + sections = [] + for label, fields in (("Pattern", value.pattern), ("Range", value.range)): + pairs = [] + for key, item in fields.model_dump(exclude_none=True).items(): + if item == []: + continue + rendered = ", ".join(item) if isinstance(item, list) else str(item) + pairs.append(f"{key}: {rendered}") + sections.append(f"{label}: " + "; ".join(pairs)) + return "\n".join(sections) + + +async def map_outlook_calendar(record: SourceRecord, directory: Path) -> ProjectionInputs: + """Use canonical originals only; conversion and retained/indexed text stay shared.""" + if ( + record.deleted_at is not None + or record.content_access != "available" + or record.payload_schema_version != 1 + or record.blobs + ): + raise ValueError("Outlook calendar projection requires active schema-one JSON originals") + if record.identity.record_type == "calendar": + if record.parent is not None or record.identity.container_id is not None: + raise ValueError("Outlook calendar must be a root identity") + native = OutlookCalendar.model_validate(record.payload) + if native.id != record.identity.native_id: + raise ValueError("Outlook calendar native identity mismatch") + entity = OutlookCalendarMetadataEntity( + native_id=native.id, + title=native.name or "Calendar", + breadcrumbs=[], + owner=_address(OutlookAddress.model_validate(native.owner)) if native.owner else None, + can_edit=native.canEdit, + can_view_private_items=native.canViewPrivateItems, + is_default_calendar=native.isDefaultCalendar, + ) + return ProjectionInputs( + parts=( + ProjectionInput( + part=ExtractionPart(part_index=0, key="/calendar", kind="record"), + entity=entity, + ), + ) + ) + parent = record.parent + if ( + record.identity.record_type != "event" + or parent is None + or parent.record_type != "calendar" + or parent.container_id is not None + or record.identity.container_id != parent.native_id + or record.completeness != "partial" + ): + raise ValueError("Outlook event requires its exact calendar parent and partial capture") + event = OutlookCalendarEvent.model_validate(record.payload) + if event.id != record.identity.native_id: + raise ValueError("Outlook event native identity mismatch") + details = _Details.model_validate(record.payload) + locations = ([details.location] if details.location else []) + details.locations + metadata = OutlookCalendarSearchEventEntity( + native_id=event.id, + title=event.subject or "Calendar event", + calendar_id=parent.native_id, + breadcrumbs=[], + created_at=record.source_created_at, + updated_at=record.source_updated_at, + event_type=event.type, + start=f"{event.start.dateTime} ({event.start.timeZone})", + end=f"{event.end.dateTime} ({event.end.timeZone})", + is_all_day=event.isAllDay, + is_cancelled=event.isCancelled, + organizer=_address(event.organizer.emailAddress) if event.organizer else None, + attendees=[_attendee(_Attendee.model_validate(value)) for value in event.attendees], + recurrence=_recurrence(_Recurrence.model_validate(event.recurrence)) + if event.recurrence is not None + else None, + series_master_id=event.seriesMasterId, + original_start=event.originalStart, + original_start_timezone=event.originalStartTimeZone, + original_end_timezone=event.originalEndTimeZone, + locations=list( + dict.fromkeys(location.displayName for location in locations if location.displayName) + ), + categories=details.categories, + show_as=details.showAs, + web_link=event.webLink, + ) + body = event.body + if body is not None and body.contentType == "html": + content = body.content.encode("utf-8") + local = await write_blob(content, directory, suffix=".html") + html_entity = OutlookCalendarHtmlEventEntity( + **metadata.model_dump(round_trip=True), + url=event.webLink or "https://outlook.office.com/calendar/", + size=len(content), + file_type="html", + mime_type="text/html", + local_path=str(local), + ) + parts = [ + ProjectionInput( + part=ExtractionPart( + part_index=0, + key="/body/content", + kind="body", + media_type="text/html", + extension=".html", + ), + entity=html_entity, + ) + ] + elif body is not None: + parts = [ + ProjectionInput( + part=ExtractionPart( + part_index=0, key="/body/content", kind="body", media_type="text/plain" + ), + entity=metadata, + native_body=NativeTextBody(text=body.content), + ) + ] + else: + parts = [ + ProjectionInput( + part=ExtractionPart(part_index=0, key="/event", kind="record"), entity=metadata + ), + ProjectionInput( + part=ExtractionPart(part_index=1, key="/body/content", kind="body"), entity=None + ), + ] + for key in ("/attachment_inventory", "/cancelledOccurrences_inventory"): + parts.append( + ProjectionInput( + part=ExtractionPart(part_index=len(parts), key=key, kind="record"), entity=None + ) + ) + return ProjectionInputs(parts=tuple(parts)) diff --git a/backend/airweave/domains/entities/canonical/outlook_projection.py b/backend/airweave/domains/entities/canonical/outlook_projection.py new file mode 100644 index 0000000000..ee2b3920a8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/outlook_projection.py @@ -0,0 +1,318 @@ +"""Offline Outlook projection from native metadata and retained, verified RFC822 bytes.""" + +import hashlib +import html +import mimetypes +import re +from datetime import datetime +from email import policy +from email.errors import MessageError +from email.message import EmailMessage +from email.parser import BytesParser +from email.utils import quote +from pathlib import Path + +from airweave.domains.entities.canonical.blob_materializer import read_blob, write_blob +from airweave.domains.entities.canonical.extraction_models import CharsetRecovery, ExtractionPart +from airweave.domains.entities.canonical.mime_text import decode_mime_text +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.entities._base import Breadcrumb +from airweave.platform.entities.outlook_mail import OutlookAttachmentEntity, OutlookMessageEntity +from airweave.platform.sources.outlook_mail_models import OutlookMessage, OutlookRecipient + +# Fail rather than publish a truncated body for pathological MIME trees. +MAX_MIME_DEPTH = 32 +MAX_MIME_PARTS = 1024 +_UNSUPPORTED = {"multipart/encrypted", "application/pkcs7-mime", "application/x-pkcs7-mime"} + + +def _address(recipient: OutlookRecipient | None) -> str | None: + if recipient is None or not recipient.emailAddress.address: + return None + address = recipient.emailAddress + # Search metadata is readable text, not a wire header requiring RFC2047 encoding. + return f'"{quote(address.name)}" <{address.address}>' if address.name else address.address + + +def _addresses(recipients: list[OutlookRecipient]) -> list[str]: + return [address for recipient in recipients if (address := _address(recipient))] + + +def _date(value: str | None) -> datetime | None: + if value is None: + return None + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("Outlook metadata timestamp requires a timezone") + return parsed + + +def _decoded(part: EmailMessage) -> bytes: + encoding = str(part.get("Content-Transfer-Encoding", "7bit")).strip().lower() + if encoding not in {"7bit", "8bit", "binary", "base64", "quoted-printable"}: + raise ValueError("Unsupported MIME transfer encoding") + if encoding == "quoted-printable": + # The stdlib deliberately accepts invalid escapes. Preserve no silently repaired text. + raw = part.get_payload(decode=False) + if not isinstance(raw, str) or re.search(r"=(?![0-9A-Fa-f]{2}|\r?\n)", raw): + raise ValueError("Malformed quoted-printable MIME payload") + content = part.get_payload(decode=True) + if not isinstance(content, bytes): + raise ValueError("MIME leaf must contain bytes") + if part.defects: + raise ValueError("Malformed MIME transfer payload") + return content + + +def _parse(content: bytes) -> EmailMessage: + try: + message = BytesParser(policy=policy.default.clone(raise_on_defect=True)).parsebytes(content) + pending = [(message, 0)] + count = 0 + while pending: + part, depth = pending.pop() + count += 1 + if depth > MAX_MIME_DEPTH or count > MAX_MIME_PARTS: + raise ValueError("Outlook MIME tree exceeds projection depth or part limit") + if part.defects or any(header.defects for header in part.values()): + raise ValueError("Malformed Outlook MIME headers") + if part.is_multipart(): + pending.extend((child, depth + 1) for child in part.iter_parts()) + else: + _decoded(part) + return message + except (MessageError, RecursionError) as error: + raise ValueError("Malformed Outlook MIME content") from error + + +def _file(part: EmailMessage) -> bool: + return ( + bool(part.get_filename()) + or part.get_content_disposition() == "attachment" + or (not part.is_multipart() and part.get_content_type() not in {"text/plain", "text/html"}) + ) + + +def _suffix(part: EmailMessage) -> str: + # MIME type is authoritative over misleading names (e.g. a PDF called image.png). + mime = part.get_content_type() + mime_suffix = mimetypes.guess_extension(mime) if mime != "application/octet-stream" else None + if mime_suffix and re.fullmatch(r"\.[a-z0-9]{1,12}", mime_suffix): + return mime_suffix + filename_suffix = Path(part.get_filename() or "").suffix.lower() + return filename_suffix if re.fullmatch(r"\.[a-z0-9]{1,12}", filename_suffix) else ".bin" + + +class OutlookProjection: + """Disposable extraction inputs; canonical originals remain unchanged.""" + + def __init__(self, record: SourceRecord, directory: Path, native: OutlookMessage): + """Bind validated native metadata and caller-owned scratch directory.""" + self.record = record + self.directory = directory + self.native = native + self.parts: list[ProjectionInput] = [] + self.fragments: list[str] = [] + self.charset_recoveries: list[CharsetRecovery] = [] + + def unsupported(self, part: EmailMessage, path: str) -> None: + """Account for retained content whose semantics are not yet extractable.""" + self.parts.append( + ProjectionInput( + part=ExtractionPart( + part_index=len(self.parts) + 1, + key=path, + kind="file", + media_type=part.get_content_type(), + ), + entity=None, + omission="unsupported_format", + ) + ) + + async def attachment(self, part: EmailMessage, path: str) -> None: + """Materialize safe content-hash paths, independent of provider filenames.""" + content = _decoded(part) + suffix = _suffix(part) + local = await write_blob(content, self.directory, suffix=suffix) + message_id = self.native.id + self.parts.append( + ProjectionInput( + part=ExtractionPart( + part_index=len(self.parts) + 1, + key=path, + kind="file", + media_type=part.get_content_type(), + extension=suffix, + ), + entity=OutlookAttachmentEntity( + composite_id=f"{message_id}:{hashlib.sha256(path.encode()).hexdigest()}", + name=part.get_filename() or "Inline content", + message_id=message_id, + attachment_id=path, + content_type=part.get_content_type(), + is_inline=part.get_content_disposition() == "inline" + or bool(part.get("Content-ID")), + content_id=str(part["Content-ID"]) if part["Content-ID"] else None, + metadata={"identity_kind": "mime_path"}, + message_web_url=self.native.webLink, + url=self.native.webLink or "https://outlook.office.com/mail/", + size=len(content), + file_type=suffix[1:], + mime_type=part.get_content_type(), + local_path=str(local), + breadcrumbs=[ + Breadcrumb( + entity_id=message_id, + name=self.native.subject or "Email message", + entity_type="OutlookMessageEntity", + ) + ], + ), + ) + ) + + async def render(self, part: EmailMessage, path: str, *, body: bool = True) -> None: + """Select one alternative while still retaining independent inline/file parts.""" + mime = part.get_content_type() + if mime.startswith("message/") or mime in _UNSUPPORTED: + self.unsupported(part, path) + return + if part.is_multipart(): + if part.get_content_disposition() == "attachment": + self.unsupported(part, path) + return + children = list(part.iter_parts()) + selected = set(range(len(children))) + if mime == "multipart/alternative": + # get_body understands nested related parts and their explicit start CID. + preferred = part.get_body(preferencelist=("html", "plain")) + selected = { + i + for i, child in enumerate(children) + if preferred is not None and any(node is preferred for node in child.walk()) + } + elif mime == "multipart/related": + start = part.get_param("start") + selected = { + next( + ( + i + for i, child in enumerate(children) + if start and child.get("Content-ID") == start + ), + 0, + ) + } + for index, child in enumerate(children): + await self.render(child, f"{path}/parts/{index}", body=body and index in selected) + return + if _file(part) or (not body and part.get("Content-ID")): + await self.attachment(part, path) + elif body: + decoded = decode_mime_text( + _decoded(part), part.get_content_charset() or "us-ascii", path + ) + if decoded.recovery is not None: + self.charset_recoveries.append(decoded.recovery) + text = decoded.text + self.fragments.append( + text if mime == "text/html" else "
" + html.escape(text) + "
" + ) + + async def body(self) -> ProjectionInput: + """Expose full decoded text and independently retained native message metadata.""" + content = "\n".join(self.fragments).encode("utf-8") + local = await write_blob(content, self.directory, suffix=".html") + native = self.native + return ProjectionInput( + part=ExtractionPart( + part_index=0, + key="/mime/body", + kind="body", + media_type="text/html", + extension=".html", + charset_recoveries=tuple(self.charset_recoveries), + ), + entity=OutlookMessageEntity( + id=native.id, + breadcrumbs=[], + folder_id=native.parentFolderId, + subject=native.subject.strip() + if native.subject and native.subject.strip() + else "Email message", + sender=_address(native.sender), + from_address=_address(native.from_), + conversation_id=native.conversationId, + to_recipients=_addresses(native.toRecipients), + cc_recipients=_addresses(native.ccRecipients), + bcc_recipients=_addresses(native.bccRecipients), + reply_to=_addresses(native.replyTo), + sent_date=_date(native.sentDateTime), + received_date=_date(native.receivedDateTime), + internet_message_id=native.internetMessageId, + is_draft=native.isDraft, + has_attachments=native.hasAttachments, + web_url_override=native.webLink, + url=native.webLink or "https://outlook.office.com/mail/", + size=len(content), + file_type="html", + mime_type="text/html", + local_path=str(local), + ), + ) + + +async def map_outlook( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> ProjectionInputs: + """Project schema-one mailbox messages without provider calls or body-preview fallback.""" + if ( + record.deleted_at is not None + or record.content_access != "available" + or record.payload_schema_version != 1 + or record.identity.record_type != "message" + or record.identity.container_id is not None + or record.parent is not None + or record.completeness not in {"partial", "metadata_only"} + ): + raise ValueError("Outlook projection requires an active schema-one mailbox message") + native = OutlookMessage.model_validate(record.payload) + if native.id != record.identity.native_id: + raise ValueError("Outlook projection identity differs from retained native metadata") + if len(record.blobs) > 1 or any( + ref.role is not None or ref.source_path is not None or ref.media_type != "message/rfc822" + for ref in record.blobs + ): + raise ValueError("Outlook projection requires one top-level RFC822 original") + if not record.blobs: + if record.completeness != "metadata_only": + raise ValueError("Missing Outlook MIME requires an explicit metadata-only capture") + return ProjectionInputs( + parts=( + ProjectionInput( + part=ExtractionPart( + part_index=0, key="/mime", kind="body", media_type="message/rfc822" + ), + entity=None, + ), + ) + ) + if record.completeness != "partial": + raise ValueError("Outlook MIME capture must report pending attachment inventory") + message = _parse(await read_blob(record, record.blobs[0], storage)) + projection = OutlookProjection(record, directory, native) + await projection.render(message, "/mime") + body = await projection.body() + if not projection.fragments and any(part.omission for part in projection.parts): + body = ProjectionInput(part=body.part, entity=None, omission="unsupported_format") + # MIME cannot prove native reference/item attachments were represented in the response. + inventory = ProjectionInput( + part=ExtractionPart( + part_index=len(projection.parts) + 1, key="/attachment_inventory", kind="record" + ), + entity=None, + ) + return ProjectionInputs(parts=(body, *projection.parts, inventory)) diff --git a/backend/airweave/domains/entities/canonical/page_source.py b/backend/airweave/domains/entities/canonical/page_source.py new file mode 100644 index 0000000000..efe1d3cd68 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/page_source.py @@ -0,0 +1,162 @@ +"""Opted-in scope pages; the existing capture pipeline owns durable progress.""" + +from typing import Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CaptureMode, + CycleConfiguration, + ProviderCheckpoint, + SourcePlan, +) +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + ScopeRemovalReason, +) +from airweave.domains.entities.canonical.scan_models import ( + ChildScopeObservation, + ScanContinuation, + ScanState, +) +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.storage.file_service import FileService + + +class CapturePage(BaseModel): + """One provider response and the continuation committed with its original records.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + records: tuple[CaptureRecord, ...] = Field(max_length=500) + discovered_records: tuple[CaptureRecord, ...] = Field( + default=(), + max_length=500, + description="Verified independent originals from the bound source; never placeholders", + ) + child_scope_observations: tuple[ChildScopeObservation, ...] = Field( + default=(), + max_length=500, + description="Complete child inventories evidenced by active originals on this page", + ) + continuation: ScanContinuation + final: bool = False + provider_checkpoint: ProviderCheckpoint | None = None + + +class CapturePlan(BaseModel): + """Immutable source plan selected once before engine cycle creation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + mode: CaptureMode = "full" + starting_checkpoint: ProviderCheckpoint | None = None + source_plan: SourcePlan = Field(default_factory=dict) + + +class InvalidCaptureCheckpoint(Exception): + """Native evidence invalidates a whole cycle boundary, not merely a page token.""" + + +@runtime_checkable +class CheckpointedPageSource(Protocol): + """Sources with a native changes API reuse the same cycle and page authority.""" + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Provider I/O occurs before the fenced cycle CAS; None requests a fresh full pass.""" + ... + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """Derive resumable page state only from the persisted immutable plan.""" + ... + + +class InvalidScanContinuation(Exception): + """The provider rejected a saved cursor; restart the whole scope, never reconcile it.""" + + +class ScopeAccessLost(Exception): + """The source's audited contract says this child container is no longer available.""" + + def __init__( + self, + message: str, + *, + removal_reason: ScopeRemovalReason = "access_revoked", + ): + """Slack keeps its confirmed-access default; other sources must choose explicitly.""" + super().__init__(message) + self.removal_reason = TypeAdapter(ScopeRemovalReason).validate_python(removal_reason) + + +class RequiredScopeAccessLost(ScopeAccessLost): + """Withdraw known unavailable content, then fail an explicitly required source selection.""" + + +@runtime_checkable +class CanonicalPageSource(Protocol): + """A source fetches pages; SQL, attempts, scope discovery and checkpointing stay outside it.""" + + canonical_record_types: tuple[str, ...] + canonical_container_parents: dict[str, str | tuple[str | None, ...]] + capture_cycle_configuration: CycleConfiguration + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Fetch one page without advancing durable state or performing writes.""" + ... + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Source-owned stable container policy; always retain the full parent identity.""" + ... + + async def confirm_absent(self, record: SourceRecord) -> None: + """Raise unless an omitted inventory record is confirmed outside accessible scope.""" + ... + + +@runtime_checkable +class KnownObjectSource(Protocol): + """Sources declaring exact known-object validation implement current reads.""" + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Return exact fresh state or explicit unavailability; errors never mean absence.""" + ... + + +class InvalidScopeCheckpoint(Exception): + """Native evidence requires a full restart of one scope, not the whole forest.""" + + +@runtime_checkable +class ScopedPageSource(Protocol): + """Mixed sources select immutable per-scope requests; the store attests authority.""" + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Freeze source parameters once before cycle creation.""" + ... + + async def prepare_scope( + self, + scope: CompletedScope, + cycle: CaptureCycle, + previous: ScanState | None, + *, + parent: SourceRecord | None, + force_full: bool, + ) -> ScopePlan: + """Called outside SQL only when starting or explicitly restarting a sweep.""" + ... + + def initial_scope_continuation( + self, scope: CompletedScope, cycle: CaptureCycle, plan: ScopePlan + ) -> ScanContinuation: + """Derive progress from immutable persisted scope and cycle parameters.""" + ... diff --git a/backend/airweave/domains/entities/canonical/projection_gc.py b/backend/airweave/domains/entities/canonical/projection_gc.py new file mode 100644 index 0000000000..11edcb2c14 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_gc.py @@ -0,0 +1,150 @@ +"""Bounded recurring reclamation; retirement fences publication before any remote delete.""" + +from datetime import datetime, timedelta +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.projection_models import ( + ProjectionCleanupPage, + ProjectionDocument, +) +from airweave.domains.entities.canonical.store import content_is_available +from airweave.domains.entities.canonical.text_models import TextArtifact +from airweave.models.entity import Entity +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.sync import Sync + + +class ProjectionGCStore: + """Retain manifests indefinitely: timed-out feed threads have no bounded lifetime.""" + + async def due(self, db: AsyncSession, *, now: datetime, limit: int = 20) -> tuple[UUID, ...]: + """Bound maintenance work independently from source sync frequency.""" + return tuple( + await db.scalars( + select(ProjectionGeneration.id) + .where( + ProjectionGeneration.next_gc_at <= now, + ) + .order_by(ProjectionGeneration.next_gc_at, ProjectionGeneration.id) + .limit(limit) + ) + ) + + async def claim( + self, + db: AsyncSession, + generation: UUID, + *, + now: datetime, + limit: int = 100, + ) -> ProjectionCleanupPage | None: + """Irrevocably retire under the same Sync lock used by publication.""" + if not 1 <= limit <= 100: + raise ValueError("Cleanup document budget must be between 1 and 100") + async with UnitOfWork(db): + initial = await db.get(ProjectionGeneration, generation) + if initial is None: + return None + await db.scalar(select(Sync.id).where(Sync.id == initial.sync_id).with_for_update()) + row = await db.scalar( + select(ProjectionGeneration) + .where( + ProjectionGeneration.id == generation, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row.next_gc_at > now: + return None + current = await db.scalar( + select(Entity).where( + Entity.id == row.record_id, + Entity.sync_id == row.sync_id, + Entity.organization_id == row.organization_id, + ) + ) + if current is not None and current.indexed_generation == generation: + available = await db.scalar( + select(content_is_available()).where(Entity.id == current.id) + ) + if not row.documents or (current.deleted_at is None and available): + row.next_gc_at = now + timedelta(days=1) + return None + # Capture and parent transitions share the Sync lock. Clear publication + # before retiring: restoration then requires a fresh generation, never + # resurrects documents that an already-issued delete may still remove. + current.indexed_generation = None + current.indexed_revision = None + current.indexed_pipeline_version = None + current.indexed_chunk_count = None + if row.retired_at is None: + row.retired_at = now + row.next_gc_at = now + timedelta(minutes=5) + row.gc_attempt = uuid4() + await db.flush() + artifacts = tuple( + TextArtifact.model_validate(item) for item in (row.text_representations or []) + ) + start, end = row.delete_cursor, row.delete_cursor + limit + artifact_start = max(0, start - len(row.documents)) + artifact_end = max(0, end - len(row.documents)) + return ProjectionCleanupPage( + generation=generation, + attempt=row.gc_attempt, + cursor=row.delete_cursor, + artifact_keys=tuple( + item.storage_key(row.sync_id, row.id) + for item in artifacts[artifact_start:artifact_end] + ), + documents=tuple( + ProjectionDocument.model_validate(item) + for item in row.documents[row.delete_cursor : row.delete_cursor + limit] + ), + ) + + async def acknowledge( + self, + db: AsyncSession, + page: ProjectionCleanupPage, + *, + now: datetime, + error: str | None = None, + ) -> None: + """Retry failed pages; successful full passes remain scheduled for late writes.""" + async with UnitOfWork(db): + row = await db.scalar( + select(ProjectionGeneration) + .where( + ProjectionGeneration.id == page.generation, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + row is None + or row.retired_at is None + or row.delete_cursor != page.cursor + or row.gc_attempt != page.attempt + ): + return + row.gc_attempt = None + if error: + row.gc_error = error[:200] + row.next_gc_at = now + timedelta(minutes=5) + return + row.gc_error = None + row.delete_cursor += len(page.documents) + len(page.artifact_keys) + if row.delete_cursor >= len(row.documents) + len(row.text_representations or []): + row.delete_cursor = 0 + row.gc_passes += 1 + row.last_gc_at = now + row.next_gc_at = now + timedelta( + minutes=min(1440, 15 * 2 ** min(row.gc_passes - 1, 7)) + ) + else: + row.next_gc_at = now + await db.flush() diff --git a/backend/airweave/domains/entities/canonical/projection_inputs.py b/backend/airweave/domains/entities/canonical/projection_inputs.py new file mode 100644 index 0000000000..791ce7fd14 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_inputs.py @@ -0,0 +1,38 @@ +"""Typed mapper outputs; source descriptors survive filtering and entity-ID stamping.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.sync_pipeline.pipeline.text_models import NativeTextBody +from airweave.platform.entities._base import BaseEntity + + +class ProjectionInput(BaseModel): + """An expected part, with None only for explicitly uncaptured original bytes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + part: ExtractionPart + entity: BaseEntity | None + native_body: NativeTextBody | None = None + omission: Literal["unsupported_format"] | None = None + + @model_validator(mode="after") + def valid_omission(self) -> "ProjectionInput": + """Explicit unsupported originals cannot also supply indexable content.""" + if self.omission is not None and (self.entity is not None or self.native_body is not None): + raise ValueError("Unsupported projection parts cannot supply an entity or text") + return self + + +class ProjectionInputs(BaseModel): + """All expected parts, including deliberate original-capture omissions.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + parts: tuple[ProjectionInput, ...] + + @property + def entities(self) -> tuple[BaseEntity, ...]: + """Materialized entities for mapper callers; coverage lives in parts.""" + return tuple(p.entity for p in self.parts if p.entity is not None) diff --git a/backend/airweave/domains/entities/canonical/projection_mappers.py b/backend/airweave/domains/entities/canonical/projection_mappers.py new file mode 100644 index 0000000000..ce774293ae --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_mappers.py @@ -0,0 +1,534 @@ +"""Typed search views of captured records. This module never contacts a provider.""" + +from __future__ import annotations + +import mimetypes +import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory + +from pydantic import JsonValue, TypeAdapter + +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.entities.canonical.projection_policy import excluded_from_search +from airweave.domains.storage.protocols import StorageBackend +from airweave.domains.sync_pipeline.processors.entity_fields import populate_base_fields +from airweave.platform.entities._base import BaseEntity, FileEntity +from airweave.platform.entities.google_calendar import ( + GoogleCalendarEventEntity, + GoogleCalendarListEntity, +) +from airweave.platform.entities.google_drive import GoogleDriveFileEntity, GoogleDriveFolderEntity +from airweave.platform.entities.slack import SlackChannelEntity, SlackMessageEntity +from airweave.platform.entities.wispr import WisprMeetingEntity, WisprNoteEntity +from airweave.platform.sources.records.sheets_manifest import GridGap + + +class ProjectionMappingError(ValueError): + """Captured representation cannot be faithfully mapped; keep projection pending.""" + + +def _string(value: JsonValue, *, default: str = "") -> str: + if value is None: + return default + if not isinstance(value, str): + raise ProjectionMappingError("Expected a captured string field") + return value + + +def _datetime(value: JsonValue) -> datetime | None: + if value is None or value == "": + return None + parsed = datetime.fromisoformat(_string(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ProjectionMappingError("Captured timestamp lacks timezone") + return parsed + + +def _drive_omissions( + paths: tuple[str, ...], *, start_index: int = 1 +) -> tuple[ProjectionInput, ...]: + """Missing native representations remain explicit beside any usable text/export.""" + return tuple( + ProjectionInput( + part=ExtractionPart(part_index=index + start_index, key=path, kind="file"), + entity=None, + ) + for index, path in enumerate(dict.fromkeys(paths)) + ) + + +def _sheet_gap_key(gap: GridGap) -> str: + """Use native sheet IDs and exclusive bounds, never mutable sheet titles.""" + key = f"/native/sheets/{gap.sheet_id}" + bounds = gap.bounds + if bounds is not None: + key += ( + f"/rows/{bounds.start_row}:{bounds.end_row}" + f"/columns/{bounds.start_column}:{bounds.end_column}" + ) + return key + + +async def _drive_document( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> ProjectionInputs: + """Verify each retained Docs representation once and preserve missing-native evidence.""" + from airweave.domains.entities.canonical.blob_materializer import read_blob + from airweave.domains.entities.canonical.workspace_docs import CapturedDocument + from airweave.platform.sources.records.workspace_manifest import ( + parse_manifest, + validate_document, + ) + + if record.payload.get("mimeType") != "application/vnd.google-apps.document": + raise ProjectionMappingError("Captured file is not a Google document") + marked = [blob for blob in record.blobs if blob.role == "representation_manifest"] + if len(marked) != 1: + raise ProjectionMappingError("Drive document requires one representation manifest") + manifest = parse_manifest( + await read_blob(record, marked[0], storage), + file_id=record.identity.native_id, + drive_version=_string(record.payload.get("version")), + blobs=record.blobs, + ) + by_digest = {blob.sha256: blob for blob in record.blobs} + missing = tuple(gap.source_path or "/native" for gap in manifest.native.missing) + if manifest.native.status == "unavailable": + if manifest.export.status != "retained": + return ProjectionInputs(parts=_drive_omissions(missing, start_index=0)) + content = await read_blob(record, by_digest[manifest.export.blob], storage) + media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + suffix = ".docx" + else: + raw = await read_blob(record, by_digest[manifest.native.document_blob], storage) + document = TypeAdapter(dict[str, JsonValue]).validate_json(raw) + validate_document(document, file_id=record.identity.native_id) + content = CapturedDocument(manifest=manifest, document=document).text().encode("utf-8") + media_type, suffix = "text/plain", ".txt" + body = await _drive_file(record, content, media_type, suffix, directory) + return ProjectionInputs(parts=(body, *_drive_omissions(missing))) + + +async def _drive( + record: SourceRecord, + storage: StorageBackend, + directory: Path, +) -> ProjectionInputs: + from airweave.domains.entities.canonical.blob_materializer import read_blob + + if record.identity.record_type != "file": + raise ProjectionMappingError("Unsupported Drive record type") + data = record.payload + if data.get("mimeType") == "application/vnd.google-apps.folder": + return ProjectionInputs( + parts=( + _projection_input( + 0, + GoogleDriveFolderEntity( + folder_id=record.identity.native_id, + title=_string(data.get("name"), default="Untitled"), + description=_string(data.get("description")), + breadcrumbs=[], + ), + ), + ) + ) + if record.completeness == "metadata_only" and not record.blobs: + return ProjectionInputs( + parts=( + ProjectionInput( + part=ExtractionPart( + part_index=0, + key=record.identity.native_id, + kind="file", + media_type=_string(data.get("mimeType")) or None, + ), + entity=None, + ), + ) + ) + omissions: tuple[ProjectionInput, ...] = () + if any(blob.role == "representation_manifest" for blob in record.blobs): + if data.get("mimeType") == "application/vnd.google-apps.spreadsheet": + from airweave.domains.entities.canonical.workspace_sheets import read_spreadsheet + + spreadsheet = await read_spreadsheet(record, storage, for_projection=True) + omissions = _drive_omissions( + tuple(_sheet_gap_key(gap) for gap in spreadsheet.manifest.native.missing) + ) + if spreadsheet.manifest.native.status == "complete": + content = spreadsheet.text().encode("utf-8") + media_type, suffix = "text/plain", ".txt" + else: + digest = spreadsheet.manifest.export.blob + if digest is None: + raise ProjectionMappingError("Partial native grid has no complete export") + blob = next(blob for blob in record.blobs if blob.sha256 == digest) + content = await read_blob(record, blob, storage) + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + suffix = ".xlsx" + else: + return await _drive_document(record, storage, directory) + else: + # Historical Drive records retain their export-only contract. + if len(record.blobs) != 1: + raise ProjectionMappingError("Drive content requires exactly one captured blob") + blob = record.blobs[0] + content = await read_blob(record, blob, storage) + media_type = blob.media_type or _string(data.get("mimeType")) + suffix = mimetypes.guess_extension(media_type) or Path(_string(data.get("name"))).suffix + if not suffix: + return ProjectionInputs( + parts=( + ProjectionInput( + part=ExtractionPart( + part_index=0, + key=record.identity.native_id, + kind="file", + media_type=media_type or None, + ), + entity=None, + omission="unsupported_format", + ), + ) + ) + suffix = suffix.lower() + body = await _drive_file(record, content, media_type, suffix, directory) + return ProjectionInputs(parts=(body, *omissions)) + + +async def _drive_file( + record: SourceRecord, content: bytes, media_type: str, suffix: str, directory: Path +) -> ProjectionInput: + """Materialize one verified representation without changing its captured original.""" + from airweave.domains.entities.canonical.blob_materializer import write_blob + + data = record.payload + local_path = await write_blob(content, directory, suffix=suffix) + metadata = { + **data, + "createdTime": data.get("createdTime") or record.observed_at.isoformat(), + "modifiedTime": data.get("modifiedTime") or record.observed_at.isoformat(), + } + entity = GoogleDriveFileEntity.from_api(metadata, breadcrumbs=[]) + entity.local_path = str(local_path) + entity.size = len(content) + entity.mime_type = media_type + entity.file_type = suffix.lstrip(".") + entity.url = entity.web_url + return _projection_input(0, entity) + + +def _calendar(record: SourceRecord) -> tuple[BaseEntity, ...]: + metadata = { + **record.payload, + "created": record.payload.get("created") or record.observed_at.isoformat(), + "updated": record.payload.get("updated") or record.observed_at.isoformat(), + } + if record.identity.record_type == "calendar": + return (GoogleCalendarListEntity.from_api(metadata),) + if record.identity.record_type != "event" or not record.identity.container_id: + raise ProjectionMappingError("Calendar event requires its native calendar identity") + return ( + GoogleCalendarEventEntity.from_api( + metadata, + calendar_key=record.identity.container_id, + breadcrumbs=[], + ), + ) + + +def _slack(record: SourceRecord) -> tuple[BaseEntity, ...]: + payload = record.payload + if record.identity.record_type == "channel": + purpose = payload.get("purpose", {}) + topic = payload.get("topic", {}) + if not isinstance(purpose, dict) or not isinstance(topic, dict): + raise ProjectionMappingError("Invalid Slack conversation description") + return ( + SlackChannelEntity( + channel_id=record.identity.native_id, + title=_string(payload.get("name"), default=record.identity.native_id), + purpose=_string(purpose.get("value")), + topic=_string(topic.get("value")), + breadcrumbs=[], + ), + ) + if record.identity.record_type != "message" or not record.identity.container_id: + raise ProjectionMappingError("Slack message requires its conversation identity") + timestamp = datetime.fromtimestamp(float(record.identity.native_id), timezone.utc) + metadata = {**payload, "channel": {"id": record.identity.container_id}} + text = payload.get("text", "") + if not isinstance(text, str): + raise ProjectionMappingError("Slack message text must be a string when present") + entity = SlackMessageEntity.from_api(metadata, breadcrumbs=[]) + entity.text = text + entity.created_at = entity.message_time = timestamp + edited = payload.get("edited") + if isinstance(edited, dict) and edited.get("ts"): + entity.updated_at = datetime.fromtimestamp(float(edited["ts"]), timezone.utc) + return (entity,) + + +def _wispr_range_text(response: dict[str, JsonValue], field: str, offset: int) -> str: + """Explicit provider absence has no transcript text; ambiguous missing data fails.""" + native = response.get(field) + if ( + field == "transcript" + and offset == 0 + and response.get("has_transcript") is False + and "transcript" in response + and native is None + ): + return "" + if not isinstance(native, str): + raise ProjectionMappingError("Wispr requested text is unavailable or malformed") + return native + + +_WISPR_TRANSCRIPT_HEADER = ( + "<<>>\n" +) +_WISPR_TRANSCRIPT_FOOTER = "\n<<>>" + + +def _wispr_text( # noqa: C901 -- keep native framing, order and offset-unit guards together + responses: list[JsonValue], field: str +) -> str: + """Reassemble retained ranges without changing native whitespace or word boundaries.""" + expected = 0 + complete = False + pieces: list[str] = [] + wrapped: bool | None = None + offset_units = {"codepoints", "utf16"} + for page in responses: + if not isinstance(page, dict): + raise ProjectionMappingError("Invalid Wispr response range") + arguments = page.get("requested_ranges") + response = page.get("response") + if not isinstance(arguments, dict) or not isinstance(response, dict): + raise ProjectionMappingError("Missing Wispr native range response") + window = arguments.get("view_" + field) + if window is None: + continue + if not isinstance(window, dict) or window.get("start_char") != expected or complete: + raise ProjectionMappingError("Wispr text ranges overlap or have a gap") + text = _wispr_range_text(response, field, expected) + if field == "transcript": + page_wrapped = text.startswith(_WISPR_TRANSCRIPT_HEADER) + if page_wrapped != text.endswith(_WISPR_TRANSCRIPT_FOOTER): + raise ProjectionMappingError("Wispr transcript framing is incomplete") + if wrapped is not None and page_wrapped != wrapped: + raise ProjectionMappingError("Wispr transcript framing changed between ranges") + wrapped = page_wrapped + if page_wrapped: + text = text[len(_WISPR_TRANSCRIPT_HEADER) : -len(_WISPR_TRANSCRIPT_FOOTER)] + markers = list( + re.finditer( + rf"(?m)^\(\.\.\.truncated, \d+ chars remaining; continue with " + rf"view_{field}\.start_char=(\d+)\.\.\.\)$", + text, + ) + ) + if len(markers) > 1: + raise ProjectionMappingError("Wispr returned ambiguous continuation markers") + marker = markers[0] if markers else None + if marker: + following = int(marker.group(1)) + if following <= expected: + raise ProjectionMappingError("Wispr continuation does not advance") + prefix = text[: marker.start()] + if marker.end() != len(text) or not prefix.endswith("\n\n"): + raise ProjectionMappingError("Wispr continuation framing is invalid") + fragment = prefix[:-2] # Remove only the observed marker separator. + length = following - expected + candidates: set[str] = set() + if len(fragment) == length: + candidates.add("codepoints") + if len(fragment.encode("utf-16-le")) // 2 == length: + candidates.add("utf16") + offset_units &= candidates + if not offset_units: + raise ProjectionMappingError("Wispr range length disagrees with continuation") + pieces.append(fragment) + expected = following + else: + pieces.append(text) + complete = True + if not complete: + raise ProjectionMappingError("Wispr text has an unfinished continuation") + return "".join(pieces) + + +def _wispr(record: SourceRecord) -> tuple[BaseEntity, ...]: + if record.identity.record_type not in {"meeting", "scratchpad_note"}: + raise ProjectionMappingError("Unsupported Wispr record type") + responses = record.payload.get("responses") + if not isinstance(responses, list) or not responses or not isinstance(responses[0], dict): + raise ProjectionMappingError("Wispr has no captured meeting content") + first = responses[0].get("response") + if not isinstance(first, dict): + raise ProjectionMappingError("Wispr meeting response is invalid") + if record.identity.record_type == "scratchpad_note": + if first.get("id") != record.identity.native_id: + raise ProjectionMappingError("Wispr scratchpad identity differs from retained identity") + return ( + WisprNoteEntity( + note_id=record.identity.native_id, + title=_string(first.get("title"), default="Note"), + content=_wispr_text(responses, "content"), + modified_at=_datetime(first.get("modified_at")), + breadcrumbs=[], + ), + ) + return ( + WisprMeetingEntity( + meeting_id=record.identity.native_id, + title=_string(first.get("title"), default="Meeting"), + notes=_wispr_text(responses, "content"), + transcript=_wispr_text(responses, "transcript"), + summary=_string(first.get("summary")), + starts_at=_datetime(first.get("start")), + modified_at=_datetime(first.get("modified_at")), + share_link=_string(first.get("share_link")) or None, + breadcrumbs=[], + ), + ) + + +@asynccontextmanager +async def map_record( # noqa: C901 -- explicit provider dispatch keeps mapper ownership visible + record: SourceRecord, + source_name: str, + storage: StorageBackend, +) -> AsyncIterator[ProjectionInputs]: + """Keep verified local blob files alive only while strict projection consumes them.""" + if record.deleted_at is not None or record.content_access != "available": + raise ProjectionMappingError("Unavailable records cannot be projected") + if excluded_from_search(record, source_name): + yield ProjectionInputs(parts=()) + return + if source_name == "almanac": + from airweave.domains.native_ingestion.projection import map_native + + yield map_native(record) + return + with TemporaryDirectory(prefix="airweave-projection-") as temporary: + directory = Path(temporary) + if source_name == "gmail": + from airweave.domains.entities.canonical.gmail_projection import map_gmail + + yield await map_gmail(record, storage, directory) + return + elif source_name == "outlook_calendar": + from airweave.domains.entities.canonical.outlook_calendar_projection import ( + map_outlook_calendar, + ) + + yield await map_outlook_calendar(record, directory) + return + elif source_name == "outlook_mail": + from airweave.domains.entities.canonical.outlook_projection import map_outlook + + yield await map_outlook(record, storage, directory) + return + elif source_name == "slack" and record.identity.record_type == "file": + from airweave.domains.entities.canonical.slack_projection import map_slack_file + + yield await map_slack_file(record, storage, directory) + return + elif source_name == "slack" and record.identity.record_type == "message": + from airweave.domains.entities.canonical.slack_projection import map_slack_files + + yield await map_slack_files(record, _slack(record)[0], storage, directory) + return + elif source_name == "notion" and record.identity.record_type == "page_property": + from airweave.domains.entities.canonical.notion_property_projection import ( + map_notion_property, + ) + + yield await map_notion_property(record, storage) + return + elif source_name == "google_drive": + yield await _drive(record, storage, directory) + return + else: + entities = await _map_entities(record, source_name, storage, directory) + parts = tuple(_projection_input(index, entity) for index, entity in enumerate(entities)) + if ( + source_name == "github" + and record.identity.record_type == "file" + and record.completeness != "complete" + ): + parts += ( + ProjectionInput( + part=ExtractionPart( + part_index=len(parts), + key="/file/content", + kind="file", + extension=Path(record.identity.native_id).suffix.lower() or None, + ), + entity=None, + ), + ) + yield ProjectionInputs(parts=parts) + + +async def _map_entities( + record: SourceRecord, source_name: str, storage: StorageBackend, directory: Path +) -> tuple[BaseEntity, ...]: + """Retain existing audited provider mappings with one part per explicit entity.""" + if source_name == "github": + from airweave.domains.entities.canonical.github_projection import map_github + + entities = await map_github(record, storage, directory) + elif source_name == "linear": + from airweave.domains.entities.canonical.linear_projection import map_linear + + entities = await map_linear(record, storage, directory) + elif source_name == "attio": + from airweave.domains.entities.canonical.attio_projection import map_attio + + entities = map_attio(record) + elif source_name == "stripe": + from airweave.domains.entities.canonical.stripe_projection import map_stripe + + entities = map_stripe(record) + elif source_name == "notion": + from airweave.domains.entities.canonical.notion_projection import map_notion + + entities = map_notion(record) + elif source_name == "google_calendar": + entities = _calendar(record) + elif source_name == "slack": + entities = _slack(record) + elif source_name == "wispr": + entities = _wispr(record) + else: + raise ProjectionMappingError("Source has no audited search projection mapper") + return entities + + +def _projection_input(index: int, entity: BaseEntity) -> ProjectionInput: + """Retain native mapper identity before generation-specific stamping.""" + populate_base_fields(entity) + file = entity if isinstance(entity, FileEntity) else None + return ProjectionInput( + part=ExtractionPart( + part_index=index, + key=entity.entity_id, + kind="file" if file is not None else "record", + media_type=file.mime_type if file is not None else None, + extension=Path(file.local_path).suffix.lower() + if file is not None and file.local_path + else None, + ), + entity=entity, + ) diff --git a/backend/airweave/domains/entities/canonical/projection_models.py b/backend/airweave/domains/entities/canonical/projection_models.py new file mode 100644 index 0000000000..f832b35afe --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_models.py @@ -0,0 +1,138 @@ +"""Typed immutable search publication identities, independent of provider identity.""" + +import re +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from airweave.domains.entities.canonical.models import SourceRecord + + +def canonical_projection_workflow_id(organization_id: UUID | str, sync_id: UUID | str) -> str: + """One execution identity shared by scheduled, capture and operator projection.""" + return f"canonical-projection:{organization_id}:{sync_id}" + + +class ProjectionLocator(BaseModel): + """Stored in the existing original_entity_id field, never a native provider ID.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_id: UUID + revision: int = Field(ge=1) + pipeline_version: int = Field(ge=1) + generation: UUID + part_index: int = Field(ge=0) + + def encode(self) -> str: + """Encode a versioned, unambiguous publication locator.""" + return ( + f"canonical:v1:{self.record_id}:{self.revision}:{self.pipeline_version}:" + f"{self.generation}:{self.part_index}" + ) + + @classmethod + def parse(cls, value: str) -> "ProjectionLocator | None": + """None identifies legacy values; malformed canonical values fail closed.""" + if not value.startswith("canonical:"): + return None + parts = value.split(":") + if len(parts) != 7 or parts[:2] != ["canonical", "v1"]: + raise ValueError("Invalid canonical projection locator") + return cls( + record_id=parts[2], + revision=parts[3], + pipeline_version=parts[4], + generation=parts[5], + part_index=parts[6], + ) + + +class ProjectionBinding(BaseModel): + """Authenticated tenant source and exact destination captured with pending work.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + source_connection_id: UUID + source_name: str + collection_id: UUID + + +class ProjectionWork(BaseModel): + """Snapshot plus publication pointer used for compare-and-swap.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + organization_id: UUID + binding: ProjectionBinding + record: SourceRecord + pipeline_version: int + previous_generation: UUID | None + + +class ProjectionSourceRef(BaseModel): + """Stored tenant/source identity eligible for recovery, without original payloads.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + organization_id: UUID + sync_id: UUID + + +class ProjectionSourcePage(BaseModel): + """Bounded source keyset page; a cursor exists only when another row was observed.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + sources: tuple[ProjectionSourceRef, ...] + next_cursor: UUID | None = None + + +class ProjectionBatchResult(BaseModel): + """One bounded scan page; failures remain pending for a later retry.""" + + after_id: UUID | None = None + has_more: bool = False + published: int = 0 + superseded: int = 0 + failed: int = 0 + + +class ProjectionDocument(BaseModel): + """Exact remote identifier, without searchable text or embeddings.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_name: str = Field(min_length=1) + document_id: str = Field(min_length=1) + + +class ProjectionCleanupPage(BaseModel): + """One idempotent deletion page of an irrevocably retired generation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + generation: UUID + attempt: UUID + cursor: int + documents: tuple[ProjectionDocument, ...] + artifact_keys: tuple[str, ...] = () + + +def scope_projection_document_id(sync_id: UUID, collection_id: UUID, document_id: str) -> str: + """Bind canonical remote IDs to the destination without changing search locators.""" + scoped = f"{sync_id}_{collection_id}_{document_id}" + projection_document_locator(scoped, sync_id, collection_id) + return scoped + + +def projection_document_locator( + document_id: str, sync_id: UUID, collection_id: UUID +) -> ProjectionLocator: + """Reject foreign prefixes and ambiguous substring matches before manifest commit.""" + prefix = f"{sync_id}_{collection_id}_" + if not document_id.startswith(prefix): + raise ValueError("Projection document scope does not match destination") + match = re.fullmatch( + r"[A-Za-z][A-Za-z0-9_]*_(canonical:v1:[^_]+)__chunk_(0|[1-9][0-9]*)", + document_id[len(prefix) :], + ) + if match is None: + raise ValueError("Invalid canonical remote document ID") + locator = ProjectionLocator.parse(match[1]) + if locator is None or locator.encode() != match[1]: + raise ValueError("Noncanonical projection locator encoding") + return locator diff --git a/backend/airweave/domains/entities/canonical/projection_policy.py b/backend/airweave/domains/entities/canonical/projection_policy.py new file mode 100644 index 0000000000..20d0a0ed77 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_policy.py @@ -0,0 +1,18 @@ +"""Pure, provider-scoped publication policy shared by mapping and store validation.""" + +from airweave.domains.entities.canonical.calendar import is_cancelled_recurring_event +from airweave.domains.entities.canonical.models import SourceRecord + + +def excluded_from_search(record: SourceRecord, source_name: str) -> bool: + """Records retained for authority/navigation that intentionally have no search text.""" + if source_name == "wispr": + return record.identity.record_type in {"meeting_listing", "scratchpad_listing"} + if source_name == "almanac": + from airweave.domains.native_ingestion.projection import excluded_native + + return excluded_native(record) + return source_name == "google_calendar" and ( + record.identity.record_type == "event_occurrence" + or (record.identity.record_type == "event" and is_cancelled_recurring_event(record.payload)) + ) diff --git a/backend/airweave/domains/entities/canonical/projection_store.py b/backend/airweave/domains/entities/canonical/projection_store.py new file mode 100644 index 0000000000..1c033aefe6 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projection_store.py @@ -0,0 +1,454 @@ +"""Canonical pending work and atomic publication. No network I/O under row locks.""" + +from collections import defaultdict +from collections.abc import Iterable +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from sqlalchemy import and_, exists, or_, select, tuple_ +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.projection_models import ( + ProjectionBinding, + ProjectionDocument, + ProjectionLocator, + ProjectionSourcePage, + ProjectionSourceRef, + ProjectionWork, + projection_document_locator, +) +from airweave.domains.entities.canonical.projection_policy import excluded_from_search +from airweave.domains.entities.canonical.store import content_is_available, source_record +from airweave.domains.entities.canonical.text_artifacts import text_manifest +from airweave.domains.entities.canonical.text_models import TextArtifact +from airweave.models.collection import Collection +from airweave.models.entity import Entity +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +def publication_matches(locator: ProjectionLocator): + """Add request organization/source scope separately; gate before text leaves retrieval.""" + return publications_match((locator,)) + + +def publications_match(locators: Iterable[ProjectionLocator]): + """Batch publication checks; callers must still validate each returned chunk's part. + + One record can match one requested part while another part is unavailable. + Organization/source scope is supplied by the caller. + """ + identities = set() + parts: dict[int, set[UUID]] = defaultdict(set) + for locator in locators: + identities.add( + (locator.record_id, locator.revision, locator.pipeline_version, locator.generation) + ) + parts[locator.part_index].add(locator.generation) + return and_( + tuple_( + Entity.id, + Entity.record_revision, + Entity.indexed_pipeline_version, + Entity.indexed_generation, + ).in_(list(identities)), + Entity.indexed_revision == Entity.record_revision, + Entity.indexed_pipeline_version == Sync.index_pipeline_version, + Entity.deleted_at.is_(None), + content_is_available(), + exists( + select(ProjectionGeneration.id) + .correlate(Entity) + .where( + ProjectionGeneration.id == Entity.indexed_generation, + ProjectionGeneration.retired_at.is_(None), + or_( + ProjectionGeneration.extraction_coverage.is_(None), + *( + and_( + ProjectionGeneration.id.in_(list(generations)), + ProjectionGeneration.extraction_coverage.contains( + {"parts": [{"part_index": part, "outcome": "indexed"}]} + ), + ) + for part, generations in parts.items() + ), + ), + ) + ), + ) + + +def _pending_record(): + """Shared exact publication/visibility eligibility for discovery and execution.""" + return and_( + Entity.record_revision > 0, + or_( + Entity.indexed_revision.is_distinct_from(Entity.record_revision), + Entity.indexed_pipeline_version.is_distinct_from(Sync.index_pipeline_version), + Entity.indexed_generation.is_(None), + ), + or_(Entity.deleted_at.is_not(None), content_is_available()), + ) + + +class CanonicalProjectionStore: + """Optimistic projection computation, serialized CAS against source capture.""" + + async def binding( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID + ) -> ProjectionBinding | None: + """Resolve one authenticated source and collection; ambiguity fails closed.""" + rows = ( + await db.execute( + select(SourceConnection.id, SourceConnection.short_name, Collection.id) + .join( + Collection, + and_( + Collection.readable_id == SourceConnection.readable_collection_id, + Collection.organization_id == SourceConnection.organization_id, + ), + ) + .join( + Sync, + and_( + Sync.id == SourceConnection.sync_id, + Sync.organization_id == SourceConnection.organization_id, + ), + ) + .where( + SourceConnection.organization_id == organization_id, + SourceConnection.sync_id == sync_id, + SourceConnection.is_authenticated.is_(True), + ) + .limit(2) + ) + ).all() + if len(rows) != 1: + return None + source, name, collection = rows[0] + return ProjectionBinding( + source_connection_id=source, source_name=name, collection_id=collection + ) + + async def admit(self, db: AsyncSession, work: ProjectionWork) -> bool: + """Fence current authorization before any converter, storage or embedding call.""" + async with UnitOfWork(db): + return await self._current(db, work) is not None + + async def pending( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + *, + after_id: UUID | None = None, + limit: int = 25, + skip_failed: bool = False, + ) -> tuple[ProjectionWork, ...]: + """One bounded UUID page; deleted records publish an empty generation.""" + if not 1 <= limit <= 100: + raise ValueError("Projection batch size must be between 1 and 100") + binding = await self.binding(db, organization_id, sync_id) + if binding is None: + return () + statement = ( + select(Entity, Sync.index_pipeline_version) + .join(Sync, Sync.id == Entity.sync_id) + .where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Sync.organization_id == organization_id, + _pending_record(), + ) + ) + if skip_failed: + statement = statement.where(Entity.projection_error.is_(None)) + if after_id is not None: + statement = statement.where(Entity.id > after_id) + rows = await db.execute(statement.order_by(Entity.id).limit(limit + 1)) + return tuple( + ProjectionWork( + organization_id=organization_id, + binding=binding, + record=source_record(row), + pipeline_version=version, + previous_generation=row.indexed_generation, + ) + for row, version in rows + ) + + async def pending_sources( + self, + db: AsyncSession, + source_names: tuple[str, ...], + *, + after_id: UUID | None = None, + limit: int = 20, + ) -> ProjectionSourcePage: + """Discover fresh pending work fairly; failures require an explicit retry. + + This system-worker query crosses tenants but returns their exact stored + scope. Execution must revalidate that scope before reading any originals. + """ + if not 1 <= limit <= 100: + raise ValueError("Projection source page size must be between 1 and 100") + owned_source = exists( + select(SourceConnection.id) + .join( + Collection, + and_( + Collection.readable_id == SourceConnection.readable_collection_id, + Collection.organization_id == SourceConnection.organization_id, + ), + ) + .where( + SourceConnection.is_authenticated.is_(True), + SourceConnection.sync_id == Sync.id, + SourceConnection.organization_id == Sync.organization_id, + SourceConnection.short_name.in_(source_names), + ) + ) + fresh_work = exists( + select(Entity.id).where( + Entity.sync_id == Sync.id, + Entity.organization_id == Sync.organization_id, + _pending_record(), + Entity.projection_error.is_(None), + ) + ) + statement = select(Sync.organization_id, Sync.id).where(owned_source, fresh_work) + if after_id is not None: + statement = statement.where(Sync.id > after_id) + rows = (await db.execute(statement.order_by(Sync.id).limit(limit + 1))).all() + sources = tuple( + ProjectionSourceRef(organization_id=organization, sync_id=sync) + for organization, sync in rows[:limit] + ) + return ProjectionSourcePage( + sources=sources, + next_cursor=sources[-1].sync_id if len(rows) > limit else None, + ) + + async def _current(self, db: AsyncSession, work: ProjectionWork) -> Entity | None: + # Capture locks Sync first too: parent visibility and revision cannot change + # between validation and publication. This transaction contains no provider I/O. + sync = await db.scalar( + select(Sync) + .where( + Sync.id == work.record.sync_id, + Sync.organization_id == work.organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if sync is None or sync.index_pipeline_version != work.pipeline_version: + return None + if await self.binding(db, work.organization_id, work.record.sync_id) != work.binding: + return None + row = ( + await db.execute( + select(Entity, content_is_available()) + .where( + Entity.id == work.record.id, + Entity.sync_id == work.record.sync_id, + Entity.organization_id == work.organization_id, + ) + .with_for_update(of=Entity) + .execution_options(populate_existing=True) + ) + ).one_or_none() + if row is None: + return None + entity, visible = row + if ( + entity.record_revision != work.record.revision + or entity.indexed_generation != work.previous_generation + ): + return None + if entity.deleted_at is None and not visible: + return None + return entity + + async def prepare( + self, + db: AsyncSession, + work: ProjectionWork, + generation: UUID, + collection_id: UUID, + documents: tuple[ProjectionDocument, ...], + *, + coverage: ExtractionCoverage | None = None, + text_representations: tuple[TextArtifact, ...] | None = None, + ) -> bool: + """Commit exact immutable deletion identities before the first remote feed.""" + if collection_id != work.binding.collection_id: + raise ValueError("Projection destination does not match authenticated binding") + identities = set() + indexed_parts = set() + for document in documents: + try: + locator = projection_document_locator( + document.document_id, work.record.sync_id, collection_id + ) + valid = ( + locator.record_id == work.record.id + and locator.revision == work.record.revision + and locator.pipeline_version == work.pipeline_version + and locator.generation == generation + ) + except (ValueError, IndexError, AttributeError): + valid = False + if not valid or (document.schema_name, document.document_id) in identities: + raise ValueError("Projection manifest must contain unique IDs of this generation") + identities.add((document.schema_name, document.document_id)) + indexed_parts.add(locator.part_index) + if coverage is not None and indexed_parts != { + p.part_index for p in coverage.parts if p.outcome == "indexed" + }: + raise ValueError("Projection documents must cover exactly the indexed parts") + text_descriptors = text_manifest( + text_representations, indexed_parts, work.record.sync_id, generation + ) + extraction = coverage.persisted() if coverage is not None else None + manifest = [document.model_dump() for document in documents] + async with UnitOfWork(db): + if await self._current(db, work) is None: + return False + prior = await db.get(ProjectionGeneration, generation) + if prior is not None: + if ( + prior.documents != manifest + or prior.extraction_coverage != extraction + or prior.text_representations != text_descriptors + or prior.record_id != work.record.id + or prior.revision != work.record.revision + or prior.pipeline_version != work.pipeline_version + or prior.organization_id != work.organization_id + or prior.collection_id != collection_id + ): + raise ValueError("Projection manifest is immutable") + return prior.retired_at is None + db.add( + ProjectionGeneration( + id=generation, + organization_id=work.organization_id, + sync_id=work.record.sync_id, + collection_id=collection_id, + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + documents=manifest, + extraction_coverage=extraction, + text_representations=text_descriptors, + next_gc_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + ) + await db.flush() + return True + + async def publish( + self, + db: AsyncSession, + work: ProjectionWork, + generation: UUID, + chunk_count: int, + ) -> bool: + """Publish only a complete current generation; late writers cannot replace it.""" + no_content = work.record.deleted_at is not None or excluded_from_search( + work.record, work.binding.source_name + ) + if chunk_count < 0: + raise ValueError("Active records require a nonempty complete projection") + if no_content and chunk_count != 0: + raise ValueError("Deleted records and search exclusions must publish no content") + async with UnitOfWork(db): + entity = await self._current(db, work) + if entity is None: + return False + attempt = await db.scalar( + select(ProjectionGeneration) + .where( + ProjectionGeneration.id == generation, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + attempt is None + or attempt.retired_at is not None + or attempt.collection_id != work.binding.collection_id + or attempt.sync_id != work.record.sync_id + or attempt.organization_id != work.organization_id + or attempt.record_id != work.record.id + or attempt.revision != work.record.revision + or attempt.pipeline_version != work.pipeline_version + or len(attempt.documents) != chunk_count + ): + return False + coverage = ( + ExtractionCoverage.model_validate(attempt.extraction_coverage) + if attempt.extraction_coverage is not None + else None + ) + if ( + not no_content + and chunk_count == 0 + and (coverage is None or not coverage.parts or coverage.status != "unavailable") + ): + raise ValueError( + "Active records require indexed content or explicit unavailable extraction" + ) + if no_content and coverage is not None and coverage.parts: + raise ValueError("Excluded records must have empty extraction coverage") + if work.previous_generation is not None: + previous = await db.get(ProjectionGeneration, work.previous_generation) + if previous is not None: + previous.retired_at = datetime.now(timezone.utc) + previous.next_gc_at = datetime.now(timezone.utc) + entity.indexed_revision = work.record.revision + entity.indexed_pipeline_version = work.pipeline_version + entity.indexed_generation = generation + entity.indexed_chunk_count = chunk_count + entity.projection_error = None + await db.flush() + return True + + async def fail(self, db: AsyncSession, work: ProjectionWork, message: str) -> None: + """Record bounded diagnostics only while the same input remains current.""" + async with UnitOfWork(db): + entity = await self._current(db, work) + if entity is not None: + entity.projection_error = message[:1000] + await db.flush() + + +async def current_extraction( + db: AsyncSession, organization_id: UUID, sync_id: UUID, record_id: UUID, revision: int +) -> ExtractionCoverage | None: + """Only the visible current revision/pipeline/publication exposes extraction evidence.""" + raw = await db.scalar( + select(ProjectionGeneration.extraction_coverage) + .join(Entity, Entity.indexed_generation == ProjectionGeneration.id) + .join(Sync, Sync.id == Entity.sync_id) + .where( + Entity.id == record_id, + Entity.record_revision == revision, + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Sync.organization_id == organization_id, + ProjectionGeneration.organization_id == organization_id, + ProjectionGeneration.record_id == Entity.id, + ProjectionGeneration.revision == Entity.record_revision, + ProjectionGeneration.pipeline_version == Sync.index_pipeline_version, + ProjectionGeneration.retired_at.is_(None), + Entity.indexed_revision == Entity.record_revision, + Entity.indexed_pipeline_version == Sync.index_pipeline_version, + Entity.deleted_at.is_(None), + content_is_available(), + ) + ) + return ExtractionCoverage.model_validate(raw) if raw is not None else None diff --git a/backend/airweave/domains/entities/canonical/projector.py b/backend/airweave/domains/entities/canonical/projector.py new file mode 100644 index 0000000000..1c7e432c98 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/projector.py @@ -0,0 +1,279 @@ +"""Bounded original-record projection using existing conversion/embed/feed primitives.""" + +from collections.abc import Callable, Iterable +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.core.logging import ContextualLogger +from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, +) +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInputs +from airweave.domains.entities.canonical.projection_models import ( + ProjectionBatchResult, + ProjectionDocument, + ProjectionLocator, + ProjectionWork, + scope_projection_document_id, +) +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.search_metadata import stamp_search_metadata +from airweave.domains.entities.canonical.text_artifacts import prepare_text +from airweave.domains.storage.protocols import StorageBackend +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.domains.sync_pipeline.processors.entity_fields import populate_base_fields +from airweave.platform.destinations.vespa.destination import VespaDestination +from airweave.platform.entities._base import AirweaveSystemMetadata, BaseEntity + + +@dataclass +class ProjectionContext: + """Projection processing has no source credentials or artificial capture job.""" + + logger: ContextualLogger + source_short_name: str + + +class StrictProjectionTracker: + """A replay publication cannot silently omit failed conversion inputs.""" + + async def record_skipped(self, count: int) -> None: + """Fail publication when an existing converter tries to skip required input.""" + if count: + raise ValueError("Required projection input could not be converted") + + +@dataclass +class ProjectionRuntime: + """Only processing diagnostics are required; source runtime is not recreated.""" + + entity_tracker: StrictProjectionTracker + + +def _stamp_chunks(chunks: Iterable[BaseEntity], record: SourceRecord) -> None: + """Conversion cannot replace the source-owned searchable metadata.""" + for chunk in chunks: + if chunk.airweave_system_metadata is None: + raise ValueError("Projection chunk lost canonical metadata") + stamp_search_metadata(chunk.airweave_system_metadata, record) + + +class CanonicalProjector: + """Read snapshot, compute outside transaction, then atomically publish generation.""" + + def __init__( + self, + store: CanonicalProjectionStore, + sessions: Callable[[], AbstractAsyncContextManager[AsyncSession]], + processor: ChunkEmbedProcessor, + storage: StorageBackend, + ): + """Inject existing processor, storage, and transaction session ownership.""" + self._store = store + self._sessions = sessions + self._processor = processor + self._storage = storage + + async def _admit( + self, work: ProjectionWork, source_name: str, destination: VespaDestination + ) -> bool: + """Close authorization transaction before any external processing begins.""" + if ( + source_name != work.binding.source_name + or destination.collection_id != work.binding.collection_id + ): + return False + async with self._sessions() as db: + return await self._store.admit(db, work) + + async def project_one( + self, + work: ProjectionWork, + source_name: str, + destination: VespaDestination, + logger: ContextualLogger, + ) -> bool: + """Never feed native capture JSON; mapper emits explicit safe projection entities.""" + from airweave.domains.entities.canonical.projection_mappers import map_record + from airweave.domains.entities.canonical.projection_policy import excluded_from_search + + if not await self._admit(work, source_name, destination): + return False + + generation = uuid4() + chunks = [] + coverage = ExtractionCoverage(parts=()) + no_documents = work.record.deleted_at is not None or excluded_from_search( + work.record, source_name + ) + if not no_documents: + async with map_record(work.record, source_name, self._storage) as mapped: + if not mapped.parts: + raise ValueError("Projection mapper returned no required content") + selected, coverage = _select_inputs( + mapped, work, source_name, generation, self._processor.supports_file_extension + ) + context = ProjectionContext(logger, source_name) + runtime = ProjectionRuntime(StrictProjectionTracker()) + selected_ids = {entity.entity_id for entity in selected} + native_bodies = { + item.entity.entity_id: item.native_body + for item in mapped.parts + if item.native_body is not None + and item.entity is not None + and item.entity.entity_id in selected_ids + } + built = await self._processor.build_text( + selected, context, runtime, native_bodies=native_bodies + ) + artifacts = prepare_text(built.representations, generation) + chunks = await self._processor.process_built_text( + built.entities, + context, + runtime, + strict=True, + expected_ids={entity.entity_id for entity in selected}, + ) + _stamp_chunks(chunks, work.record) + prepared = destination.prepare_documents(chunks) + for group in prepared.values(): + for document in group: + document.id = scope_projection_document_id( + work.record.sync_id, destination.collection_id, document.id + ) + manifest = tuple( + ProjectionDocument(schema_name=doc.schema_name, document_id=doc.id) + for group in prepared.values() + for doc in group + ) + async with self._sessions() as db: + if not await self._store.prepare( + db, + work, + generation, + destination.collection_id, + manifest, + coverage=coverage, + text_representations=tuple(item for item, _ in artifacts), + ): + return False + for artifact, content in artifacts: + await self._storage.write_file( + artifact.storage_key(work.record.sync_id, generation), content + ) + await destination.feed_prepared(prepared) + if no_documents: + async with self._sessions() as db: + if not await self._store.prepare( + db, + work, + generation, + destination.collection_id, + (), + coverage=coverage, + text_representations=(), + ): + return False + async with self._sessions() as db: + return await self._store.publish(db, work, generation, len(chunks)) + + async def batch( + self, + organization_id: UUID, + sync_id: UUID, + source_name: str, + destination: VespaDestination, + logger: ContextualLogger, + *, + after_id: UUID | None = None, + limit: int = 25, + skip_failed: bool = False, + ) -> ProjectionBatchResult: + """Failed rows stay pending; other rows in the page continue to make progress.""" + async with self._sessions() as db: + pending = await self._store.pending( + db, + organization_id, + sync_id, + after_id=after_id, + limit=limit, + **({"skip_failed": True} if skip_failed else {}), + ) + published = superseded = failed = 0 + for work in pending[:limit]: + try: + if await self.project_one(work, source_name, destination, logger): + published += 1 + else: + superseded += 1 + except Exception as error: + failed += 1 + async with self._sessions() as db: + await self._store.fail(db, work, type(error).__name__) + logger.warning( + "Canonical projection failed for record %s (%s)", + work.record.id, + type(error).__name__, + ) + return ProjectionBatchResult( + after_id=pending[min(limit, len(pending)) - 1].record.id if pending else after_id, + has_more=len(pending) > limit, + published=published, + superseded=superseded, + failed=failed, + ) + + +def _select_inputs( + mapped: ProjectionInputs, + work: ProjectionWork, + source_name: str, + generation: UUID, + supports_file_extension: Callable[[str], bool], +) -> tuple[list[BaseEntity], ExtractionCoverage]: + """Classify only deterministic omissions, preserving stable pre-filter part ordinals.""" + selected = [] + outcomes = [] + for item in mapped.parts: + part, entity = item.part, item.entity + if item.omission == "unsupported_format": + outcome, reason = "unsupported", "unsupported_format" + elif entity is None: + outcome, reason = "unavailable_original", "original_not_captured" + elif ( + part.kind == "file" + and part.extension is not None + and not supports_file_extension(part.extension) + ): + outcome, reason = "unsupported", "unsupported_format" + else: + outcome, reason = "indexed", None + selected.append(entity) + outcomes.append(ExtractionOutcome(**part.model_dump(), outcome=outcome, reason=reason)) + if outcome != "indexed": + continue + part_index = part.part_index + populate_base_fields(entity) + if entity.airweave_system_metadata is None: + entity.airweave_system_metadata = AirweaveSystemMetadata() + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=part_index, + ) + entity.entity_id = locator.encode() + meta = entity.airweave_system_metadata + meta.source_name = source_name + meta.entity_type = type(entity).__name__ + meta.sync_id = work.record.sync_id + meta.sync_job_id = None + meta.db_entity_id = work.record.id + stamp_search_metadata(meta, work.record) + return selected, ExtractionCoverage(parts=tuple(outcomes)) diff --git a/backend/airweave/domains/entities/canonical/query.py b/backend/airweave/domains/entities/canonical/query.py new file mode 100644 index 0000000000..634f737c8e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/query.py @@ -0,0 +1,376 @@ +"""Exact reads and continuation contracts over the owned record store.""" + +from uuid import UUID + +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.blob_materializer import BlobIntegrityError, read_blob +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.query_models import ( + DocumentRead, + MailThreadCursor, + MailThreadPage, + RecordChangePage, + RecordCursor, + RecordListQuery, + RecordPage, + SpreadsheetRead, +) +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore, CanonicalStoreError +from airweave.domains.entities.canonical.workspace_docs import read_document +from airweave.domains.entities.canonical.workspace_sheets import read_spreadsheet +from airweave.domains.storage.exceptions import StorageException +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.sources.records.sheets_manifest import GridBounds + + +class InvalidRecordCursor(CanonicalStoreError): + """A cursor belongs to another scope/query or cannot be verified.""" + + code = "invalid_cursor" + + +class RecordNotFound(CanonicalStoreError): + """No canonical record is visible at this identity.""" + + code = "record_not_found" + + +class StaleRecordRevision(CanonicalStoreError): + """The client must reread the current record before using its blob reference.""" + + code = "stale_record_revision" + + +class BlobNotFound(CanonicalStoreError): + """No authorized committed bytes match this record and digest.""" + + code = "blob_not_found" + + +class BlobUnavailable(CanonicalStoreError): + """Committed bytes are missing or fail integrity verification.""" + + code = "blob_unavailable" + + +class DocumentUnavailable(CanonicalStoreError): + """This record has no valid retained native document representation.""" + + code = "document_unavailable" + + +class SpreadsheetUnavailable(CanonicalStoreError): + """Native spreadsheet representation or selected sheet cannot be read at this revision.""" + + code = "spreadsheet_unavailable" + + +class DocumentIncomplete(CanonicalStoreError): + """Retained native document fields do not cover the requested document.""" + + code = "document_incomplete" + + +class CanonicalQueryService: + """Own read semantics; cursor signatures never replace tenant authorization.""" + + def __init__( + self, records: CanonicalRecordStore, queries: CanonicalQueryStore, signing_key: str + ) -> None: + """Bind persistence and the existing deployment secret used for signed cursors.""" + self.records = records + self.queries = queries + self.signing_key = signing_key + + def _encode(self, cursor: RecordCursor | MailThreadCursor) -> str: + return jwt.encode(cursor.model_dump(mode="json"), self.signing_key, algorithm="HS256") + + def _decode(self, token: str, organization_id: UUID, sync_id: UUID, mode: str) -> RecordCursor: + try: + cursor = RecordCursor.model_validate( + jwt.decode(token, self.signing_key, algorithms=["HS256"]) + ) + except (JWTError, ValidationError, ValueError) as exc: + raise InvalidRecordCursor("Invalid continuation cursor; restart this query") from exc + if ( + cursor.organization_id != organization_id + or cursor.sync_id != sync_id + or cursor.mode != mode + ): + raise InvalidRecordCursor("Cursor belongs to a different source or operation") + return cursor + + async def read( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, record_id: UUID + ) -> SourceRecord: + """Return exact current state, including an explicit tombstone if deleted.""" + record = await self.records.read(db, organization_id, sync_id, record_id) + if record is None: + raise RecordNotFound("Record not found in this source") + return record + + async def list_records( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, query: RecordListQuery + ) -> RecordPage: + """Traverse current committed rows; callers must preserve filters on continuation.""" + after_id = None + if query.cursor is not None: + cursor = self._decode(query.cursor, organization_id, sync_id, "list") + if cursor.filters != query.filters: + raise InvalidRecordCursor("Preserve the original filters when continuing a list") + after_id = cursor.after_id + parent = None + if query.filters.parent_record_id is not None: + parent = await self.read(db, organization_id, sync_id, query.filters.parent_record_id) + if parent.deleted_at is not None or parent.content_access != "available": + raise RecordNotFound("Record not found in this source") + rows = await self.queries.list_records( + db, + organization_id, + sync_id, + query.filters, + after_id=after_id, + limit=query.limit, + parent=parent.identity if parent else None, + ) + more = len(rows) > query.limit + page = rows[: query.limit] + next_cursor = None + if more: + next_cursor = self._encode( + RecordCursor( + mode="list", + organization_id=organization_id, + sync_id=sync_id, + filters=query.filters, + after_id=page[-1].id, + ) + ) + coverage = await capture_coverage(db, organization_id, (sync_id,)) + return RecordPage( + records=page, next_cursor=next_cursor, has_more=more, capture=coverage.get(sync_id) + ) + + async def changes( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + *, + cursor: str | None = None, + limit: int = 100, + ) -> RecordChangePage: + """Start at the beginning; finish each committed window before polling the next.""" + position = ( + self._decode(cursor, organization_id, sync_id, "changes") + if cursor + else RecordCursor(mode="changes", organization_id=organization_id, sync_id=sync_id) + ) + page = await self.records.changes( + db, + organization_id, + sync_id, + after=position.after_sequence, + limit=limit, + high_watermark=position.high_watermark, + ) + next_cursor = self._encode( + RecordCursor( + mode="changes", + organization_id=organization_id, + sync_id=sync_id, + after_sequence=page.next_sequence, + high_watermark=page.high_watermark if page.has_more else None, + ) + ) + return RecordChangePage( + changes=page.changes, + next_cursor=next_cursor, + has_more=page.has_more, + high_watermark=page.high_watermark, + ) + + async def mail_thread( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + thread_id: str, + *, + cursor: str | None = None, + limit: int = 100, + ) -> MailThreadPage: + """Return stored messages only; observation time never proves complete coverage.""" + if not thread_id or len(thread_id) > 512 or not 1 <= limit <= 100: + raise ValueError("Invalid thread query bounds") + position = None + if cursor is not None: + try: + position = MailThreadCursor.model_validate( + jwt.decode(cursor, self.signing_key, algorithms=["HS256"]) + ) + except (JWTError, ValidationError, ValueError) as exc: + raise InvalidRecordCursor("Invalid thread cursor; restart this query") from exc + if (position.organization_id, position.sync_id, position.thread_id) != ( + organization_id, + sync_id, + thread_id, + ): + raise InvalidRecordCursor("Cursor belongs to a different account or thread") + rows = await self.queries.mail_thread( + db, + organization_id, + sync_id, + thread_id, + after_id=position.after_id if position else None, + after_created_at=position.after_created_at if position else None, + limit=limit, + ) + more = len(rows) > limit + messages = rows[:limit] + next_cursor = None + if more: + last = messages[-1] + next_cursor = self._encode( + MailThreadCursor( + organization_id=organization_id, + sync_id=sync_id, + thread_id=thread_id, + after_created_at=last.source_created_at, + after_id=last.id, + ) + ) + return MailThreadPage( + thread_id=thread_id, messages=messages, next_cursor=next_cursor, has_more=more + ) + + async def document( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + storage: StorageBackend, + ) -> DocumentRead: + """Read verified stored Docs bytes under current record and ancestor authority.""" + record = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(record, revision) + try: + captured = await read_document(record, storage) + except (StorageException, BlobIntegrityError) as exc: + raise BlobUnavailable("Committed document bytes are unavailable; retry later") from exc + except ValueError as exc: + raise DocumentUnavailable( + "No valid stored Google document is available; refresh this source and reread." + ) from exc + db.expire_all() + current = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(current, revision) + if record.blobs != current.blobs: + raise StaleRecordRevision("Document changed; reread before requesting its content") + if captured.manifest.native.status != "complete": + raise DocumentIncomplete( + "Stored document content is incomplete; refresh this source and reread." + ) + return DocumentRead( + id=current.id, + identity=current.identity, + revision=current.revision, + observed_at=current.observed_at, + completeness=current.completeness, + manifest=captured.manifest, + document=captured.document, + ) + + async def spreadsheet( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + storage: StorageBackend, + bounds: GridBounds | None = None, + ) -> SpreadsheetRead: + """Read a revision-pinned native grid; provider access is never a fallback.""" + if bounds is not None and ( + (bounds.end_row - bounds.start_row) * (bounds.end_column - bounds.start_column) > 10000 + ): + raise SpreadsheetUnavailable("Requested grid exceeds the 10000-cell read limit") + record = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(record, revision) + try: + captured = await read_spreadsheet(record, storage, bounds=bounds) + except (StorageException, BlobIntegrityError) as exc: + raise BlobUnavailable( + "Committed spreadsheet bytes are unavailable; retry later" + ) from exc + except ValueError as exc: + raise SpreadsheetUnavailable( + "No valid native spreadsheet is retained; refresh and reread" + ) from exc + try: + cells = captured.cells(bounds) if bounds is not None else () + except ValueError as exc: + raise SpreadsheetUnavailable( + "Requested grid is outside retained sheet dimensions" + ) from exc + db.expire_all() + current = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(current, revision) + if record.blobs != current.blobs: + raise StaleRecordRevision("Spreadsheet changed; reread before requesting content") + return SpreadsheetRead( + id=current.id, + identity=current.identity, + revision=current.revision, + observed_at=current.observed_at, + completeness=current.completeness, + spreadsheet=captured.spreadsheet, + grid_status=captured.manifest.native.status, + captured=tuple(p.bounds for p in captured.manifest.native.parts), + missing=captured.manifest.native.missing, + requested=bounds, + cells=tuple(cells), + ) + + async def blob( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + sha256: str, + storage: StorageBackend, + ) -> bytes: + """Resolve only committed references and recheck visibility after storage I/O.""" + record = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(record, revision) + ref = next((item for item in record.blobs if item.sha256 == sha256), None) + if ref is None: + raise BlobNotFound("Blob reference not found on this record") + try: + content = await read_blob(record, ref, storage) + except (StorageException, BlobIntegrityError) as exc: + raise BlobUnavailable("Committed blob is unavailable; retry later") from exc + # Drop identity-map values before the second READ COMMITTED visibility check. + db.expire_all() + current = await self.read(db, organization_id, sync_id, record_id) + self._check_blob_record(current, revision) + if ref not in current.blobs: + raise BlobNotFound("Blob reference no longer belongs to this record") + return content + + @staticmethod + def _check_blob_record(record: SourceRecord, revision: int) -> None: + if record.content_access != "available" or record.deleted_at is not None: + raise RecordNotFound("Record content is not currently available") + if record.revision != revision: + raise StaleRecordRevision("Record changed; reread before requesting its blob") diff --git a/backend/airweave/domains/entities/canonical/query_models.py b/backend/airweave/domains/entities/canonical/query_models.py new file mode 100644 index 0000000000..df37eb203d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/query_models.py @@ -0,0 +1,120 @@ +"""Typed exact-record queries; search ranking is a separate index concern.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue + +from airweave.domains.entities.canonical.models import IndexedRecordRead as IndexedRecordRead +from airweave.domains.entities.canonical.models import ObservedChange, SourceRecord +from airweave.domains.entities.canonical.models import RecordPage as RecordPage +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.platform.sources.records.sheets_manifest import GridBounds, GridGap +from airweave.platform.sources.records.sheets_models import SpreadsheetCell +from airweave.platform.sources.records.workspace_manifest import WorkspaceManifestV1 + + +class DocumentRead(BaseModel): + """One authorized stored revision's native document and representation coverage.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + identity: RecordIdentity + revision: int = Field(ge=1) + observed_at: AwareDatetime + completeness: Literal["complete", "partial", "metadata_only"] + manifest: WorkspaceManifestV1 + document: dict[str, JsonValue] + + +class SpreadsheetRead(BaseModel): + """Stored workbook metadata and bounded native cells; unknown ranges stay unknown.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + identity: RecordIdentity + revision: int = Field(ge=1) + observed_at: AwareDatetime + completeness: Literal["complete", "partial", "metadata_only"] + spreadsheet: dict[str, JsonValue] + grid_status: Literal["complete", "partial"] + captured: tuple[GridBounds, ...] + missing: tuple[GridGap, ...] + requested: GridBounds | None = None + cells: tuple[SpreadsheetCell, ...] = () + comments: Literal["omitted"] = "omitted" + embedded_media: Literal["not_retained"] = "not_retained" + calculated_values: Literal["observed_at_capture"] = "observed_at_capture" + + +class RecordFilters(BaseModel): + """Supported exact filters, preserved in every continuation token.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_type: str | None = None + container_id: str | None = None + parent_record_id: UUID | None = None + state: Literal["active", "deleted", "all"] = "active" + + +class RecordListQuery(BaseModel): + """Stable-ID ordered live traversal, explicitly not a frozen snapshot.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + filters: RecordFilters = Field(default_factory=RecordFilters) + limit: int = Field(default=100, ge=1, le=500) + cursor: str | None = None + + +class RecordChangePage(BaseModel): + """A bounded journal window plus a cursor for future polling.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + changes: tuple[ObservedChange, ...] + next_cursor: str + has_more: bool + high_watermark: int + + +class RecordCursor(BaseModel): + """Versioned signed cursor payload; no provider secrets or record content.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + purpose: Literal["canonical_records"] = "canonical_records" + version: Literal[1] = 1 + mode: Literal["list", "changes"] + organization_id: UUID + sync_id: UUID + filters: RecordFilters | None = None + after_id: UUID | None = None + after_sequence: int = Field(default=0, ge=0) + high_watermark: int | None = Field(default=None, ge=0) + + +class MailThreadPage(BaseModel): + """Observed messages only; neither a complete thread nor a provider snapshot.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + thread_id: str + messages: tuple[SourceRecord, ...] + next_cursor: str | None + has_more: bool + consistency: Literal["live"] = "live" + order: Literal["source_created_at_asc_nulls_last_id_asc"] = ( + "source_created_at_asc_nulls_last_id_asc" + ) + coverage: Literal["stored_messages_only"] = "stored_messages_only" + + +class MailThreadCursor(BaseModel): + """Signed thread and account traversal position, independent of capture containers.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + purpose: Literal["canonical_mail_thread"] = "canonical_mail_thread" + version: Literal[1] = 1 + organization_id: UUID + sync_id: UUID + thread_id: str + after_created_at: datetime | None + after_id: UUID diff --git a/backend/airweave/domains/entities/canonical/query_store.py b/backend/airweave/domains/entities/canonical/query_store.py new file mode 100644 index 0000000000..fa6f309b8f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/query_store.py @@ -0,0 +1,139 @@ +"""Read-only SQL for exact canonical record listing.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import and_, func, literal_column, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.query_models import RecordFilters +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import ( + SourceNotFound, + content_is_available, + parent_is_visible, + source_record, + with_content_access, +) +from airweave.models.entity import Entity +from airweave.models.sync import Sync + + +class CanonicalQueryStore: + """No provider or index calls; reads only committed canonical rows.""" + + async def captured_counts( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + ) -> dict[str, int]: + """Count currently visible captured records, independent of index publication.""" + rows = await db.execute( + select(Entity.entity_definition_short_name, func.count(Entity.id)) + .where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + content_is_available(), + ) + .group_by(Entity.entity_definition_short_name) + ) + return dict(rows.all()) + + async def list_records( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + filters: RecordFilters, + *, + after_id: UUID | None, + limit: int, + parent: RecordIdentity | None = None, + ) -> tuple[SourceRecord, ...]: + """Fetch one extra row so continuation does not require a count query.""" + scope = await db.scalar( + select(Sync.id).where(Sync.id == sync_id, Sync.organization_id == organization_id) + ) + if scope is None: + raise SourceNotFound("Source does not exist in this organization") + statement = select(Entity).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + parent_is_visible(), + ) + if filters.parent_record_id is not None and parent is None: + raise ValueError("Parent filter requires an authorized parent identity") + if parent is not None: + statement = statement.where( + Entity.parent_record_type == parent.record_type, + Entity.parent_native_id == parent.native_id, + Entity.parent_container_id.is_not_distinct_from(parent.container_id), + ) + if filters.record_type is not None: + statement = statement.where(Entity.entity_definition_short_name == filters.record_type) + if filters.container_id is not None: + statement = statement.where(Entity.container_id == filters.container_id) + if filters.state == "active": + statement = statement.where(Entity.deleted_at.is_(None)) + elif filters.state == "deleted": + statement = statement.where(Entity.deleted_at.is_not(None)) + if after_id is not None: + statement = statement.where(Entity.id > after_id) + rows = await db.scalars(statement.order_by(Entity.id).limit(limit + 1)) + return tuple( + with_content_access( + source_record(row), row.removal_reason not in ("access_revoked", "scope_removed") + ) + for row in rows + ) + + async def mail_thread( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + thread_id: str, + *, + after_id: UUID | None, + after_created_at: datetime | None, + limit: int, + ) -> tuple[SourceRecord, ...]: + """Use native Gmail thread identity, keeping mailbox capture containers unchanged.""" + scope = await db.scalar( + select(Sync.id).where(Sync.id == sync_id, Sync.organization_id == organization_id) + ) + if scope is None: + raise SourceNotFound("Source does not exist in this organization") + statement = select(Entity).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.entity_definition_short_name == "message", + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + content_is_available(), + Entity.source_payload.op("->>")(literal_column("'threadId'")) == thread_id, + ) + if after_id is not None: + if after_created_at is None: + statement = statement.where( + Entity.source_created_at.is_(None), Entity.id > after_id + ) + else: + statement = statement.where( + or_( + Entity.source_created_at > after_created_at, + Entity.source_created_at.is_(None), + and_(Entity.source_created_at == after_created_at, Entity.id > after_id), + ) + ) + rows = await db.scalars( + statement.order_by(Entity.source_created_at.asc().nulls_last(), Entity.id).limit( + limit + 1 + ) + ) + return tuple(source_record(row) for row in rows) diff --git a/backend/airweave/domains/entities/canonical/reprojection.py b/backend/airweave/domains/entities/canonical/reprojection.py new file mode 100644 index 0000000000..39b22d5450 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/reprojection.py @@ -0,0 +1,111 @@ +"""Scoped operator version change; existing projection rows remain the durable queue.""" + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.projection_models import canonical_projection_workflow_id +from airweave.domains.entities.canonical.search_metadata import SEARCH_METADATA_PIPELINE_VERSION +from airweave.models.collection import Collection +from airweave.models.entity import Entity +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +class ReprojectionPlan(BaseModel): + """Inspectable target and counts, with no provider credentials.""" + + model_config = ConfigDict(frozen=True) + organization_id: UUID + sync_id: UUID + source_connection_id: UUID + provider: str + current_version: int + target_version: int + captured_records: int + target_pending_records: int + workflow_id: str + applied: bool + + +async def plan_reprojection( + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + *, + expected_version: int, + target_version: int, + apply: bool = False, +) -> ReprojectionPlan: + """Caller commits before starting workflow; identical retries never bump twice.""" + if expected_version < 1 or target_version != expected_version + 1: + raise ValueError("Target version must be exactly expected version plus one") + if target_version < SEARCH_METADATA_PIPELINE_VERSION: + raise ValueError("Target version lacks current canonical search metadata") + sync = await db.scalar( + select(Sync) + .where( + Sync.id == sync_id, + Sync.organization_id == organization_id, + ) + .with_for_update() + ) + if sync is None: + raise ValueError("Selected organization/sync is unavailable") + connection = ( + await db.scalars( + select(SourceConnection) + .join( + Collection, + ( + (Collection.readable_id == SourceConnection.readable_collection_id) + & (Collection.organization_id == organization_id) + ), + ) + .where( + SourceConnection.organization_id == organization_id, + SourceConnection.sync_id == sync_id, + SourceConnection.is_authenticated.is_(True), + ) + ) + ).one_or_none() + if connection is None: + raise ValueError("Selected authenticated source binding is unavailable") + if sync.index_pipeline_version not in (expected_version, target_version): + raise ValueError("Projection version changed; inspect before choosing a new operation") + count, pending = ( + await db.execute( + select( + func.count(), + func.count().filter( + Entity.indexed_pipeline_version.is_distinct_from(target_version) + | Entity.indexed_revision.is_distinct_from(Entity.record_revision) + | Entity.indexed_generation.is_(None) + ), + ).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + ) + ) + ).one() + if not count: + raise ValueError("Selected source has no captured canonical records") + plan = ReprojectionPlan( + organization_id=organization_id, + sync_id=sync_id, + source_connection_id=connection.id, + provider=connection.short_name, + current_version=sync.index_pipeline_version, + target_version=target_version, + captured_records=count, + target_pending_records=pending, + workflow_id=canonical_projection_workflow_id(organization_id, sync_id), + applied=apply, + ) + if apply: + sync.index_pipeline_version = target_version + await db.flush() + return plan diff --git a/backend/airweave/domains/entities/canonical/requests.py b/backend/airweave/domains/entities/canonical/requests.py new file mode 100644 index 0000000000..5ea1ed87a3 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/requests.py @@ -0,0 +1,196 @@ +"""Validated source capture commands. Provider payloads remain opaque JSON.""" + +import hashlib +import json +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + field_validator, + model_serializer, + model_validator, +) + +ScopeRemovalReason = Literal["scope_removed", "access_revoked"] +RecordKind = Annotated[str, Field(min_length=1, max_length=200)] + + +class ScanVersion(BaseModel): + """Exact durable scope version observed before provider I/O.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + sweep_id: UUID + revision: int = Field(ge=1) + + +class RecordIdentity(BaseModel): + """Native identity inside one sync, including provider-required container.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_type: RecordKind + native_id: str = Field(min_length=1) + container_id: str | None = None + + @property + def entity_key(self) -> str: + """Unambiguous encoding; native IDs remain separately available for actions.""" + return json.dumps( + [self.container_id, self.native_id], ensure_ascii=False, separators=(",", ":") + ) + + +def parent_container_key(parent: RecordIdentity) -> str: + """Optional source identity policy: bounded full-parent locator, stable across rebuilds. + + New connectors choosing this policy use it for every child, including root-owned + children. Native parent identity remains in CaptureRecord.parent and native JSON. + Existing connectors may retain their audited provider-global container IDs. + """ + material = json.dumps( + parent.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + return "parent:" + hashlib.sha256(material.encode()).hexdigest() + + +class BlobReference(BaseModel): + """An already-written immutable blob required by this record.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + key: str = Field(min_length=1) + sha256: str = Field(pattern=r"^[a-f0-9]{64}$") + size_bytes: int = Field(ge=0) + media_type: str | None = None + filename: str | None = Field( + default=None, + description="Provider filename or format-specific export label; never a storage path", + ) + source_path: str | None = Field( + default=None, description="RFC 6901 JSON Pointer into the unchanged native payload" + ) + + role: Literal["representation_manifest"] | None = None + + @model_validator(mode="after") + def manifest_is_not_native_location(self) -> "BlobReference": + """A representation manifest is provenance, not part of the provider payload.""" + if self.role is not None and self.source_path is not None: + raise ValueError("Representation manifests cannot have a native source path") + return self + + @model_serializer(mode="wrap") + def historical_descriptor(self, handler): + """Preserve historical hashes without dropping unrelated nullable fields.""" + value = handler(self) + if self.filename is None: + value.pop("filename", None) + if self.role is None: + value.pop("role", None) + return value + + +class CaptureRecord(BaseModel): + """One current provider observation; sparse deletions are valid records.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + identity: RecordIdentity + parent: RecordIdentity | None = None + allow_reparent: bool = Field( + default=False, + description="Audited provider evidence that this same object moved; never a retry override", + ) + descendant_visibility_fields: tuple[ + Annotated[str, Field(min_length=1, max_length=200)], ... + ] = Field( + default=(), + max_length=8, + description="Audited native top-level fields whose change invalidates descendant access", + ) + payload: dict[str, JsonValue] + payload_schema_version: int = Field(default=1, ge=1) + kind: Literal["upsert", "delete"] = "upsert" + removal_reason: ( + Literal["provider_deleted", "scope_removed", "access_revoked", "absent"] | None + ) = None + completeness: Literal["complete", "metadata_only", "partial"] = "complete" + content_hash: str | None = None + source_created_at: AwareDatetime | None = None + source_updated_at: AwareDatetime | None = None + observed_at: AwareDatetime + blobs: tuple[BlobReference, ...] = () + + @field_validator("descendant_visibility_fields") + @classmethod + def unique_visibility_fields(cls, value): + """Source declarations are bounded native names, never paths or expressions.""" + if len(set(value)) != len(value) or any( + not key.strip() or key != key.strip() for key in value + ): + raise ValueError("Descendant visibility fields require unique nonblank native names") + return value + + @model_validator(mode="after") + def validate_removal(self) -> "CaptureRecord": + """Keep provider deletion distinct from inaccessible or deselected scope.""" + if sum(blob.role == "representation_manifest" for blob in self.blobs) > 1: + raise ValueError("A record can retain at most one representation manifest") + if self.kind == "delete" and self.removal_reason is None: + raise ValueError("delete observations require removal_reason") + if self.kind == "upsert" and self.removal_reason is not None: + raise ValueError("upsert observations cannot have removal_reason") + return self + + +class WriterFence(BaseModel): + """Only the current source run may persist records or its checkpoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + organization_id: UUID + sync_id: UUID + job_id: UUID + epoch: int = Field(ge=1) + attempt_id: UUID + attempt_number: int = Field(ge=1) + + +class CaptureBatch(BaseModel): + """A bounded atomic capture batch; order within the batch is preserved.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + records: tuple[CaptureRecord, ...] = Field(max_length=500) + + +class CompletedScope(BaseModel): + """Evidence for one exact type/container and, for nested pages, its full owner.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_type: RecordKind + container_id: str | None = None + parent: RecordIdentity | None = None + + +class ReconcileScope(BaseModel): + """Only call after successful complete enumeration, never partial failure.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + scope: CompletedScope + removal_reason: Literal["absent", "scope_removed"] = "absent" + observed_at: AwareDatetime + limit: int = Field(default=250, ge=1, le=500) + + +class StartedScope(CompletedScope): + """Start/restart exact-scope full enumeration, discarding earlier attempt sightings.""" + + +class RemovedScope(CompletedScope): + """Provider-confirmed scope loss; applies to children even if seen in this run.""" + + removal_reason: ScopeRemovalReason + observed_at: AwareDatetime diff --git a/backend/airweave/domains/entities/canonical/scan_models.py b/backend/airweave/domains/entities/canonical/scan_models.py new file mode 100644 index 0000000000..2828e0e0d6 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/scan_models.py @@ -0,0 +1,150 @@ +"""Declared-scope page commits; incomplete discovery never owns absence reconciliation.""" + +import json +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, field_validator + +from airweave.domains.entities.canonical.cycle_models import ( + TERMINAL_CHECKPOINT_KEY, + CompletionPolicy, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.models import CaptureResult, SourceRecord +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + ScanVersion, + WriterFence, +) +from airweave.domains.entities.canonical.scope_execution import ScopeExecution, ScopePlan + + +class ScanContinuation(BaseModel): + """Connector-validated opaque progress; never original content or an unbounded queue.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + value: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("value") + @classmethod + def bounded(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + """Bound serialized state before it enters a transaction.""" + if TERMINAL_CHECKPOINT_KEY in value: + raise ValueError("Source continuation uses a reserved engine key") + if len(json.dumps(value, allow_nan=False, ensure_ascii=False).encode()) > 65536: + raise ValueError("Scan continuation exceeds 64 KiB") + return value + + +class ChildScopeObservation(BaseModel): + """Current native page certifies this exact owner's complete child inventory. + + The provider validates inventory completeness, independently of unrelated body + coverage. SQL derives the receipt from the captured owner, never caller versions. + This declaration is page input, not a persisted queue or a resumed-page receipt. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + scope: CompletedScope + continuation: ScanContinuation = Field(default_factory=ScanContinuation) + + +class BeginScan(BaseModel): + """Resume a cycle, or explicitly CAS-restart its sweep after invalid continuation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + scope: CompletedScope + cycle_id: UUID + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + expected: ScanVersion | None = None + restart: bool = False + plan: ScopePlan | None = None + expected_parent_epoch: int | None = Field(default=None, ge=1) + expected_parent_revision: int | None = Field(default=None, ge=1) + exact_parent_observation: CaptureRecord | None = None + continuation: ScanContinuation = Field(default_factory=ScanContinuation) + + +class ScanState(BaseModel): + """Complete means the declared policy finished, not necessarily exhaustive discovery.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + scope: CompletedScope + cycle_id: UUID + version: ScanVersion + phase: Literal["collecting", "reconciling", "complete"] + fingerprint: str + continuation: ScanContinuation + started_at: AwareDatetime + completed_at: AwareDatetime | None + parent_visibility_epoch: int | None = None + parent_verified_attempt_id: UUID | None = None + parent_verified_revision: int | None = None + membership_attempt_id: UUID | None = None + completion_policy: CompletionPolicy = "exhaustive" + mode: Literal["full", "changes"] = "full" + provider_checkpoint: ProviderCheckpoint | None = None + execution: ScopeExecution | None = None + + +class ScanAdmission(BaseModel): + """An unavailable refreshed owner is committed without admitting child work.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + state: ScanState | None + parent: SourceRecord | None + capture: CaptureResult + + +class CommitScanPage(BaseModel): + """Records and next state become durable together under one writer fence.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + scope: CompletedScope + cycle_id: UUID + expected: ScanVersion + records: tuple[CaptureRecord, ...] = Field(max_length=500) + discovered_records: tuple[CaptureRecord, ...] = Field( + default=(), + max_length=500, + description="Verified independent originals from the bound source; never placeholders", + ) + child_scope_observations: tuple[ChildScopeObservation, ...] = Field(default=(), max_length=500) + continuation: ScanContinuation + final: bool = False + provider_checkpoint: ProviderCheckpoint | None = None + + +class ReconcileScan(BaseModel): + """One bounded removal batch, only after the final collection page committed.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + scope: CompletedScope + cycle_id: UUID + expected: ScanVersion + observed_at: AwareDatetime + removal_reason: Literal["absent", "scope_removed"] = "absent" + limit: int = Field(default=250, ge=1, le=500) + + +class ScanResult(BaseModel): + """Acknowledged SQL state plus changes committed by this step.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + state: ScanState + capture: CaptureResult + + +class CommitOmission(BaseModel): + """Fresh exact read after discovery; never broad absence evidence.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + fence: WriterFence + state: ScanState + expected_record: SourceRecord + observation: CaptureRecord diff --git a/backend/airweave/domains/entities/canonical/scan_store.py b/backend/airweave/domains/entities/canonical/scan_store.py new file mode 100644 index 0000000000..ba6d8ef5f6 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/scan_store.py @@ -0,0 +1,860 @@ +"""Scan persistence inside the existing capture transaction; no provider I/O or commits.""" + +import json +from datetime import datetime, timezone +from typing import Protocol +from uuid import UUID, uuid4 + +from pydantic import JsonValue +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.cycle_models import ( + TERMINAL_CHECKPOINT_KEY, + CaptureCycle, + CaptureMode, + CycleVersion, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.cycle_store import ( + CycleConflict, + attest_cycle, + attest_scope, + cursor_row, + cycle_state, + persist, + scope_owner, +) +from airweave.domains.entities.canonical.models import CaptureResult, SourceRecord +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CaptureRecord, + CompletedScope, + ReconcileScope, + ScopeRemovalReason, + WriterFence, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitOmission, + CommitScanPage, + ReconcileScan, + ScanAdmission, + ScanContinuation, + ScanResult, + ScanState, + ScanVersion, +) +from airweave.domains.entities.canonical.scope_execution import ( + ScopeEvidence, + ScopeExecution, + ScopePlan, +) +from airweave.domains.entities.canonical.store import ( + CanonicalRecordStore, + CanonicalStoreError, + content_is_available, + source_record, +) +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor + + +class ScanConflict(CanonicalStoreError): + """Reload durable progress before performing more provider I/O.""" + + code = "scan_conflict" + + +def scope_key(scope: CompletedScope, parent_id: UUID | None = None) -> str: + """Distinguish null containers from empty strings without a nullable unique key.""" + key = json.dumps( + [scope.record_type, scope.container_id], ensure_ascii=False, separators=(",", ":") + ) + return key if parent_id is None else key + "|" + str(parent_id) + + +def scan_state(row: CaptureScan, parent: Entity | None = None) -> ScanState: + """Do not leak ORM state outside its transaction.""" + return ScanState( + scope=CompletedScope( + record_type=row.record_type, + container_id=row.container_id, + parent=source_record(parent).identity if parent is not None else None, + ), + cycle_id=row.cycle_id, + version=ScanVersion(sweep_id=row.sweep_id, revision=row.revision), + phase=row.phase, + fingerprint=row.fingerprint, + continuation=ScanContinuation( + value={ + key: value + for key, value in row.continuation.items() + if key != TERMINAL_CHECKPOINT_KEY + } + ), + provider_checkpoint=ProviderCheckpoint.model_validate( + row.continuation[TERMINAL_CHECKPOINT_KEY] + ) + if TERMINAL_CHECKPOINT_KEY in row.continuation + else None, + started_at=row.started_at, + completed_at=row.completed_at, + parent_visibility_epoch=row.parent_visibility_epoch, + membership_attempt_id=row.membership_attempt_id, + parent_verified_attempt_id=row.parent_verified_attempt_id, + parent_verified_revision=row.parent_verified_revision, + execution=ScopeExecution.model_validate(row.execution_state) + if row.execution_state + else None, + ) + + +def page_continuation(request: CommitScanPage, mode: CaptureMode) -> dict[str, JsonValue]: + """The terminal boundary commits with the records it certifies, never in a later call.""" + if request.provider_checkpoint is not None and not request.final: + raise ScanConflict("Only a terminal page may certify a provider checkpoint") + if mode == "changes" and request.final and request.provider_checkpoint is None: + raise ScanConflict("Changes terminal page requires a provider checkpoint") + if mode == "changes" and any(record.removal_reason == "absent" for record in request.records): + raise ScanConflict("Changes require explicit deletion evidence, never absence") + continuation = dict(request.continuation.value) + if request.provider_checkpoint is not None: + continuation[TERMINAL_CHECKPOINT_KEY] = request.provider_checkpoint.model_dump(mode="json") + return continuation + + +def effective_mode(row: CaptureScan, cycle: CaptureCycle) -> str: + """A mixed cycle never supplies a default cleanup mode for its scopes.""" + if cycle.mode != "mixed": + return cycle.mode + if row.execution_state is None and row.cycle_id != cycle.version.cycle_id: + return "full" # Legacy evidence is unknown; prepare a new full scope. + if row.execution_state is None: + raise ScanConflict("Mixed scope is missing its immutable execution plan") + return ScopeExecution.model_validate(row.execution_state).plan.mode + + +def publish_scope(row: CaptureScan, cycle: CaptureCycle, sequence: int, attempt_id: UUID) -> None: + """Completed evidence is derived only from this row's committed terminal state.""" + if row.execution_state is None or row.phase != "complete": + return + execution = ScopeExecution.model_validate(row.execution_state) + checkpoint = row.continuation.get(TERMINAL_CHECKPOINT_KEY) + evidence = ScopeEvidence( + cycle_id=row.cycle_id, + sweep_id=row.sweep_id, + completed_at=row.completed_at, + parent_visibility_epoch=row.parent_visibility_epoch, + request_context=execution.plan.request_context, + policy=cycle.configuration.policy(row.record_type), + observed_change_sequence=sequence, + writer_attempt_id=attempt_id, + checkpoint=ProviderCheckpoint.model_validate(checkpoint) if checkpoint else None, + ) + row.execution_state = execution.model_copy( + update={ + "published": evidence, + "last_full": evidence if execution.plan.mode == "full" else execution.last_full, + } + ).model_dump(mode="json") + + +def begin_execution( + row: CaptureScan, cycle: CaptureCycle, request: BeginScan, epoch: int | None +) -> None: + """Previous completion survives replacement; mismatching owner/request invalidates it.""" + if cycle.mode != "mixed": + if request.plan is None: + row.execution_state = None + return + if ( + cycle.mode != "full" + or request.plan.mode != "full" + or request.plan.starting_checkpoint is not None + ): + raise ScanConflict("Full capture scope plans cannot request incremental execution") + plan = request.plan + if plan is None: + raise ScanConflict("Mixed capture requires an explicit scope plan") + previous = ( + ScopeExecution.model_validate(row.execution_state) + if row.execution_state and row.fingerprint == cycle.configuration.fingerprint + else None + ) + full = previous.last_full if previous else None + published = previous.published if previous else None + policy = cycle.configuration.policy(request.scope.record_type) + if full and (not full.matches(plan, epoch) or full.policy != policy): + full = None + if published and (not published.matches(plan, epoch) or published.policy != policy): + published = None + validate_scope_changes(cycle, request.scope.record_type, plan, full, published) + row.execution_state = ScopeExecution(plan=plan, last_full=full, published=published).model_dump( + mode="json" + ) + + +def validate_scope_changes( + cycle: CaptureCycle, + kind: str, + plan: ScopePlan, + full: ScopeEvidence | None, + published: ScopeEvidence | None, +) -> None: + """Only compatible completed evidence authorizes an incremental scope.""" + if plan.mode == "changes": + if cycle.force_full_scopes: + raise ScanConflict("This cycle requires full scope scans") + if kind not in cycle.configuration.scope_changes: + raise ScanConflict("Scope kind does not permit native changes") + if full is None or published is None or published.checkpoint is None: + raise ScanConflict("Scope changes require compatible full evidence and checkpoint") + if plan.starting_checkpoint != published.checkpoint: + raise ScanConflict("Scope changes must start at its last published checkpoint") + + +def attest_planned_owner(request: BeginScan, parent: Entity | None) -> None: + """The post-I/O plan must still describe the captured owner used to select it.""" + if request.expected_parent_epoch != (parent.visibility_epoch if parent else None) or ( + (request.plan is not None or request.exact_parent_observation is not None) + and request.expected_parent_revision != (parent.record_revision if parent else None) + ): + raise ScanConflict("Scope owner changed during plan selection") + + +def preserve_owner_verification( + row: CaptureScan, request: BeginScan, parent: Entity | None +) -> None: + """A cursor restart preserves only a receipt still bound to this exact owner.""" + keep_verification = bool( + parent + and row.cycle_id == request.cycle_id + and row.parent_visibility_epoch == parent.visibility_epoch + and row.parent_verified_attempt_id == request.fence.attempt_id + and row.parent_verified_revision == parent.record_revision + ) + if not keep_verification: + row.parent_verified_attempt_id = None + row.parent_verified_revision = None + + +class LockedPageCapture(Protocol): + """Internal composition: capture the entire page under the caller's writer lock. + + Implementations must not commit. This is code-owned behavior, never HTTP data. + The surrounding scan transaction owns scope checks and continuation advancement. + """ + + async def __call__( + self, db: AsyncSession, sync: Sync, batch: CaptureBatch, *, seen_id: UUID + ) -> CaptureResult: + """Capture changes and sightings atomically using the existing sweep identity.""" + ... + + +class CanonicalScanStore: + """Reuse record capture, lock ordering and journal semantics for recoverable scans.""" + + def __init__(self, records: CanonicalRecordStore): + """The existing record store remains the only authority for record mutations.""" + self.records = records + + async def _row( + self, db: AsyncSession, fence: WriterFence, scope: CompletedScope + ) -> CaptureScan | None: + cycle = cycle_state(await cursor_row(db, fence)) + if cycle is None: + raise CycleConflict("Scan requires a persisted cycle") + parent = await scope_owner(db, fence, cycle, scope) + predicates = [ + CaptureScan.organization_id == fence.organization_id, + CaptureScan.sync_id == fence.sync_id, + ] + if parent is None: + predicates.append(CaptureScan.scope_key == scope_key(scope)) + else: + predicates.extend( + ( + CaptureScan.parent_record_id == parent.id, + CaptureScan.record_type == scope.record_type, + ) + ) + row = await db.scalar( + select(CaptureScan) + .where(*predicates) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is not None and row.container_id != scope.container_id: + raise CycleConflict( + "Established scope identity mapping changed; explicit migration required" + ) + return row + + async def _state(self, db: AsyncSession, row: CaptureScan) -> ScanState: + parent = await db.get(Entity, row.parent_record_id) if row.parent_record_id else None + if row.parent_record_id is not None and parent is None: + raise CycleConflict("Scope parent disappeared; explicitly restart the cycle") + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == row.sync_id)) + cycle = cycle_state(cursor) if cursor else None + return scan_state(row, parent).model_copy( + update={ + "completion_policy": cycle.configuration.policy(row.record_type) + if cycle + else "exhaustive", + "mode": effective_mode(row, cycle) if cycle else "full", + } + ) + + async def read( + self, db: AsyncSession, fence: WriterFence, scope: CompletedScope + ) -> ScanState | None: + """Read only under the active writer fence, including after an uncertain commit.""" + await self.records._fenced_sync(db, fence) + row = await self._row(db, fence, scope) + return await self._state(db, row) if row else None + + @staticmethod + def _expect(row: CaptureScan | None, expected: ScanVersion, cycle_id: UUID) -> CaptureScan: + if ( + row is None + or row.cycle_id != cycle_id + or row.sweep_id != expected.sweep_id + or row.revision != expected.revision + ): + raise ScanConflict("Scan changed; reload its durable state") + return row + + @staticmethod + def _check_parent_epoch(row: CaptureScan, parent: Entity | None) -> None: + if row.parent_visibility_epoch != (parent.visibility_epoch if parent else None): + raise ScanConflict("Scope owner changed; restart its sweep with current CAS") + + async def admit(self, db: AsyncSession, request: BeginScan) -> ScanAdmission: + """Commit fresh owner state and its child receipt under the existing writer fence.""" + sync = await self.records._fenced_sync(db, request.fence) + cursor, cycle = await attest_cycle(db, request.fence, request.cycle_id) + await attest_scope( + db, + request.fence, + cycle, + request.scope, + require_owner_receipt=request.exact_parent_observation is None, + ) + parent = await scope_owner(db, request.fence, cycle, request.scope) + observation = request.exact_parent_observation + empty = CaptureResult(changes=(), sequence=sync.observed_change_sequence, unchanged=0) + if observation is None: + state = await self._begin(db, request, context=(cursor, cycle, parent)) + return ScanAdmission( + state=state, parent=source_record(parent) if parent else None, capture=empty + ) + if ( + parent is None + or parent.entity_definition_short_name + not in cycle.configuration.exact_parent_validation + ): + raise ScanConflict("Scope does not accept exact parent verification") + attest_planned_owner(request, parent) + if request.fingerprint != cycle.configuration.fingerprint: + raise CycleConflict("Scan configuration differs from the active cycle") + row = await self._row(db, request.fence, request.scope) + if row is None: + if request.expected is not None: + raise ScanConflict("Verification expected an existing scope") + elif request.expected is None: + raise ScanConflict("Verification requires the current scope version") + else: + self._expect(row, request.expected, row.cycle_id) + previous = source_record(parent) + if ( + observation.identity != previous.identity + or observation.parent != previous.parent + or observation.allow_reparent + or observation.removal_reason == "absent" + ): + raise ScanConflict("Exact parent observation changed identity or claimed absence") + captured = await self.records._capture_locked( + db, + sync, + CaptureBatch(fence=request.fence, records=(observation,)), + mark_seen=False, + ) + await db.refresh(parent) + available = await db.scalar(select(content_is_available()).where(Entity.id == parent.id)) + if parent.deleted_at is not None or not available: + return ScanAdmission(state=None, parent=source_record(parent), capture=captured) + state = await self._begin_verified(db, request, context=(cursor, cycle, parent)) + return ScanAdmission(state=state, parent=source_record(parent), capture=captured) + + async def _begin_verified( + self, + db: AsyncSession, + request: BeginScan, + *, + context: tuple[SyncCursor, CaptureCycle, Entity], + ) -> ScanState: + """Share child admission after a fenced native owner observation is captured.""" + cursor, cycle, parent = context + row = await self._row(db, request.fence, request.scope) + updated = request.model_copy( + update={ + "expected_parent_epoch": parent.visibility_epoch, + "expected_parent_revision": parent.record_revision, + "restart": request.restart + or bool(row and row.parent_visibility_epoch != parent.visibility_epoch), + } + ) + await self._begin(db, updated, context=(cursor, cycle, parent)) + row = await self._row(db, request.fence, request.scope) + row.parent_verified_attempt_id = request.fence.attempt_id + row.parent_verified_revision = parent.record_revision + row.revision += 1 + await db.flush() + return await self._state(db, row) + + async def begin(self, db: AsyncSession, request: BeginScan) -> ScanState: + """Ordinary admission cannot accept uncommitted owner observations.""" + if request.exact_parent_observation is not None: + raise ScanConflict("Exact parent observations require typed scan admission") + return await self._begin(db, request) + + async def _begin_context( + self, + db: AsyncSession, + request: BeginScan, + ) -> tuple[SyncCursor, CaptureCycle, Entity | None]: + """Ordinary entry obtains the same authority checks as exact admission.""" + await self.records._fenced_sync(db, request.fence) + cursor, cycle = await attest_cycle(db, request.fence, request.cycle_id) + await attest_scope(db, request.fence, cycle, request.scope) + parent = await scope_owner(db, request.fence, cycle, request.scope) + return cursor, cycle, parent + + async def _begin( + self, + db: AsyncSession, + request: BeginScan, + *, + context: tuple[SyncCursor, CaptureCycle, Entity | None] | None = None, + ) -> ScanState: + """Resume unchanged scans; reuse admission checks held under the same writer lock.""" + cursor, cycle, parent = context or await self._begin_context(db, request) + attest_planned_owner(request, parent) + inventory = cycle.configuration.fresh_inventory(request.scope.record_type) + if request.fingerprint != cycle.configuration.fingerprint: + raise CycleConflict("Scan configuration differs from the active cycle") + row = await self._row(db, request.fence, request.scope) + if ( + row is not None + and row.cycle_id == request.cycle_id + and inventory + and row.membership_attempt_id != request.fence.attempt_id + and not request.restart + ): + raise CycleConflict("Restart root or ancestor membership for this writer attempt") + if row is None: + if request.expected is not None or request.restart: + raise ScanConflict("Cannot restart a missing scan") + row = CaptureScan( + organization_id=request.fence.organization_id, + sync_id=request.fence.sync_id, + scope_key=scope_key(request.scope, parent.id if parent is not None else None), + parent_record_id=parent.id if parent is not None else None, + record_type=request.scope.record_type, + container_id=request.scope.container_id, + revision=1, + ) + db.add(row) + else: + if request.expected is not None: + self._expect(row, request.expected, row.cycle_id) + if ( + row.cycle_id == request.cycle_id + and row.fingerprint == request.fingerprint + and not request.restart + ): + self._check_parent_epoch(row, parent) + if request.plan is not None and ( + not row.execution_state + or ScopeExecution.model_validate(row.execution_state).plan != request.plan + ): + raise ScanConflict("Active scope plan changed; explicit restart required") + return await self._state(db, row) + if request.expected is None: + raise ScanConflict("Replacing a scan requires its current version") + if row.cycle_id == request.cycle_id and not request.restart: + raise ScanConflict("Changed scope configuration requires an explicit restart") + row.revision += 1 + begin_execution(row, cycle, request, parent.visibility_epoch if parent else None) + preserve_owner_verification(row, request, parent) + row.parent_visibility_epoch = parent.visibility_epoch if parent else None + row.membership_attempt_id = request.fence.attempt_id if inventory else None + row.cycle_id = request.cycle_id + row.sweep_id = uuid4() + row.fingerprint = request.fingerprint + row.phase = "collecting" + row.continuation = request.continuation.value + row.started_at = datetime.now(timezone.utc) + row.completed_at = None + persist( + cursor, + cycle.model_copy( + update={ + "version": CycleVersion( + cycle_id=cycle.version.cycle_id, + revision=cycle.version.revision + 1, + ) + } + ), + ) + await db.flush() + return await self._state(db, row) + + @staticmethod + def _validate_child_observations(request: CommitScanPage, cycle: CaptureCycle) -> None: + """Only exact active owners in this native page can attest child inventories.""" + if not request.child_scope_observations: + return + if cycle.mode != "full": + raise ScanConflict("Page child observations require a full capture cycle") + records = {record.identity: record for record in request.records} + seen = set() + for observation in request.child_scope_observations: + scope = observation.scope + parent = records.get(scope.parent) + if ( + parent is None + or parent.kind != "upsert" + or parent.allow_reparent + or parent.removal_reason is not None + or parent.identity.record_type not in cycle.configuration.exact_parent_validation + or parent.identity.record_type + not in cycle.configuration.parents.get(scope.record_type, ()) + ): + raise ScanConflict( + "Child observation requires an allowed current-page upsert owner" + ) + key = (scope.parent, scope.record_type) + if key in seen: + raise ScanConflict("Duplicate child observation for the same owner and kind") + seen.add(key) + + async def _observe_child_scopes(self, db: AsyncSession, request: CommitScanPage) -> None: + """Derive existing child receipts inside the page's atomic writer transaction.""" + for observation in request.child_scope_observations: + # _begin may advance the cycle version; never reuse an earlier snapshot. + cursor, cycle = await attest_cycle(db, request.fence, request.cycle_id) + scope = observation.scope + await attest_scope(db, request.fence, cycle, scope, require_owner_receipt=False) + parent = await scope_owner(db, request.fence, cycle, scope) + if parent is None or parent.deleted_at is not None: + raise ScanConflict("Child observation owner is unavailable") + previous = await self._row(db, request.fence, scope) + request_begin = BeginScan( + fence=request.fence, + scope=scope, + cycle_id=request.cycle_id, + fingerprint=cycle.configuration.fingerprint, + expected=( + ScanVersion(sweep_id=previous.sweep_id, revision=previous.revision) + if previous is not None + else None + ), + restart=bool( + previous + and previous.cycle_id == request.cycle_id + and cycle.configuration.fresh_inventory(scope.record_type) + and previous.membership_attempt_id != request.fence.attempt_id + ), + expected_parent_epoch=parent.visibility_epoch, + expected_parent_revision=parent.record_revision, + continuation=observation.continuation, + ) + await self._begin_verified(db, request_begin, context=(cursor, cycle, parent)) + + async def page( + self, + db: AsyncSession, + request: CommitScanPage, + *, + capture_page: LockedPageCapture | None = None, + ) -> ScanResult: + """Capture and advance the page as one transaction, never a partial acknowledgement.""" + sync = await self.records._fenced_sync(db, request.fence) + _, cycle = await attest_cycle(db, request.fence, request.cycle_id) + await attest_scope(db, request.fence, cycle, request.scope) + row = self._expect( + await self._row(db, request.fence, request.scope), request.expected, request.cycle_id + ) + mode = effective_mode(row, cycle) + continuation = page_continuation(request, mode) + parent = await scope_owner(db, request.fence, cycle, request.scope) + if row.parent_visibility_epoch != (parent.visibility_epoch if parent else None): + raise ScanConflict("Scope owner changed; restart from its current epoch") + if ( + cycle.configuration.fresh_inventory(row.record_type) + and row.membership_attempt_id != request.fence.attempt_id + ): + raise CycleConflict("Membership belongs to an earlier writer attempt") + if row.phase != "collecting": + raise ScanConflict("Scan is not collecting pages") + if any( + record.identity.record_type != row.record_type + or record.identity.container_id != row.container_id + for record in request.records + ): + raise ScanConflict("Page contains records outside its exact scope") + expected_parent = source_record(parent).identity if parent is not None else None + if any(record.parent != expected_parent for record in request.records): + raise ScanConflict("Page records must retain their exact declared parent identity") + combined = (*request.records, *request.discovered_records) + identities = { + (record.identity.record_type, record.identity.entity_key) for record in combined + } + if len(combined) > 500 or len(identities) != len(combined): + raise ScanConflict("A page must contain at most 500 distinct original identities") + if any( + record.identity.record_type not in cycle.configuration.root_record_types + or cycle.configuration.policy(record.identity.record_type) == "exhaustive" + or record.parent is not None + or record.identity.container_id is not None + or record.kind != "upsert" + or record.allow_reparent + or not record.payload + for record in request.discovered_records + ): + raise ScanConflict("Discovered originals must be verified independent declared roots") + self._validate_child_observations(request, cycle) + capture = capture_page or self.records._capture_locked + captured = await capture( + db, + sync, + CaptureBatch(fence=request.fence, records=request.records), + seen_id=row.sweep_id, + ) + discovered = await self.records._capture_locked( + db, + sync, + CaptureBatch(fence=request.fence, records=request.discovered_records), + mark_seen=False, + ) + captured = CaptureResult( + changes=(*captured.changes, *discovered.changes), + sequence=discovered.sequence, + unchanged=captured.unchanged + discovered.unchanged, + ) + await self._observe_child_scopes(db, request) + row.continuation = continuation + row.revision += 1 + if request.final: + row.phase = "complete" if mode == "changes" else "reconciling" + row.completed_at = datetime.now(timezone.utc) if mode == "changes" else None + publish_scope(row, cycle, sync.observed_change_sequence, request.fence.attempt_id) + await db.flush() + return ScanResult(state=await self._state(db, row), capture=captured) + + async def withdraw_parent( + self, + db: AsyncSession, + fence: WriterFence, + parent: SourceRecord, + *, + expected_epoch: int, + removal_reason: ScopeRemovalReason, + ) -> CaptureResult: + """A failed old provider route cannot withdraw a renamed or revived current owner.""" + sync = await self.records._fenced_sync(db, fence) + current = await db.scalar( + select(Entity) + .where( + Entity.id == parent.id, + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + ) + .execution_options(populate_existing=True) + ) + if ( + parent.sync_id != fence.sync_id + or current is None + or current.record_revision != parent.revision + or current.visibility_epoch != expected_epoch + or source_record(current).identity != parent.identity + ): + raise ScanConflict("Scope owner changed during provider access check; reload work") + observation = CaptureRecord( + identity=parent.identity, + parent=parent.parent, + payload=parent.payload, + payload_schema_version=parent.payload_schema_version, + completeness=parent.completeness, + content_hash=parent.content_hash, + source_created_at=parent.source_created_at, + source_updated_at=parent.source_updated_at, + blobs=parent.blobs, + kind="delete", + removal_reason=removal_reason, + observed_at=datetime.now(timezone.utc), + ) + return await self.records._capture_locked( + db, sync, CaptureBatch(fence=fence, records=(observation,)) + ) + + async def missing( + self, db: AsyncSession, fence: WriterFence, state: ScanState, *, after: UUID | None = None + ) -> tuple[SourceRecord, ...]: + """Bounded exact inventory omissions, only after current-attempt collection.""" + await self.records._fenced_sync(db, fence) + _, cycle = await attest_cycle(db, fence, state.cycle_id) + if cycle.mode == "changes": + raise ScanConflict("Changes scans cannot reconcile or validate enumeration absence") + await attest_scope(db, fence, cycle, state.scope) + row = self._expect(await self._row(db, fence, state.scope), state.version, state.cycle_id) + if effective_mode(row, cycle) == "changes": + raise ScanConflict("Changes scans cannot validate enumeration absence") + parent = await scope_owner(db, fence, cycle, state.scope) + if ( + row.phase != "reconciling" + or ( + cycle.configuration.fresh_inventory(row.record_type) + and row.membership_attempt_id != fence.attempt_id + ) + or row.parent_visibility_epoch != (parent.visibility_epoch if parent else None) + ): + raise CycleConflict("Omission checks require completed current-attempt inventory") + identity = source_record(parent).identity if parent else None + predicates = [ + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name == row.record_type, + Entity.container_id.is_not_distinct_from(row.container_id), + Entity.parent_record_type.is_not_distinct_from( + identity.record_type if identity else None + ), + Entity.parent_native_id.is_not_distinct_from(identity.native_id if identity else None), + Entity.parent_container_id.is_not_distinct_from( + identity.container_id if identity else None + ), + Entity.record_revision > 0, + Entity.last_seen_run_id.is_distinct_from(row.sweep_id), + ] + if ( + cycle.configuration.policy(row.record_type) != "discovery_with_validation" + and row.record_type not in cycle.configuration.known_object_validation + ): + predicates.append(Entity.deleted_at.is_(None)) + if after is not None: + predicates.append(Entity.id > after) + records = ( + await db.scalars(select(Entity).where(*predicates).order_by(Entity.id).limit(100)) + ).all() + return tuple(source_record(record) for record in records) + + async def omission(self, db: AsyncSession, request: CommitOmission) -> ScanResult: + """Commit exact known-object validation with its sweep acknowledgement.""" + sync = await self.records._fenced_sync(db, request.fence) + _, cycle = await attest_cycle(db, request.fence, request.state.cycle_id) + if cycle.mode == "changes": + raise ScanConflict("Changes scans cannot reconcile or validate enumeration absence") + if ( + cycle.configuration.policy(request.state.scope.record_type) + != "discovery_with_validation" + and request.state.scope.record_type not in cycle.configuration.known_object_validation + ): + raise ScanConflict("This scope does not accept known-object validation") + candidates = await self.missing(db, request.fence, request.state) + previous = request.expected_record + current = next((record for record in candidates if record.id == previous.id), None) + if ( + current is None + or current.revision != previous.revision + or current.identity != previous.identity + ): + raise ScanConflict("Known object changed or is outside the next validation batch") + observation = request.observation + if observation.removal_reason == "absent": + raise ScanConflict("Discovery requires explicit unavailability, never absence") + if observation.identity != current.identity or observation.parent != current.parent: + raise ScanConflict("Known-object validation changed identity or owner") + row = self._expect( + await self._row(db, request.fence, request.state.scope), + request.state.version, + request.state.cycle_id, + ) + captured = await self.records._capture_locked( + db, + sync, + CaptureBatch(fence=request.fence, records=(observation,)), + seen_id=row.sweep_id, + ) + row.revision += 1 + await db.flush() + return ScanResult(state=await self._state(db, row), capture=captured) + + async def reconcile(self, db: AsyncSession, request: ReconcileScan) -> ScanResult: + """A final page is necessary; absence completion is durable and bounded.""" + sync = await self.records._fenced_sync(db, request.fence) + _, cycle = await attest_cycle(db, request.fence, request.cycle_id) + if cycle.mode == "changes": + raise ScanConflict("Changes scans cannot reconcile or validate enumeration absence") + await attest_scope(db, request.fence, cycle, request.scope) + row = self._expect( + await self._row(db, request.fence, request.scope), request.expected, request.cycle_id + ) + if effective_mode(row, cycle) == "changes": + raise ScanConflict("Changes scans cannot reconcile enumeration absence") + parent = await scope_owner(db, request.fence, cycle, request.scope) + if row.parent_visibility_epoch != (parent.visibility_epoch if parent else None): + raise ScanConflict("Scope owner changed; restart from its current epoch") + if ( + cycle.configuration.fresh_inventory(row.record_type) + and row.membership_attempt_id != request.fence.attempt_id + ): + raise CycleConflict("Membership belongs to an earlier writer attempt") + if row.phase != "reconciling": + raise ScanConflict("Only fully collected scans can reconcile absence") + policy = cycle.configuration.policy(row.record_type) + if ( + policy == "discovery_with_validation" + or row.record_type in cycle.configuration.known_object_validation + ) and await self.missing(db, request.fence, await self._state(db, row)): + raise ScanConflict("Known objects still require exact validation") + if policy != "exhaustive": + row.phase = "complete" + row.completed_at = datetime.now(timezone.utc) + row.revision += 1 + publish_scope(row, cycle, sync.observed_change_sequence, request.fence.attempt_id) + await db.flush() + return ScanResult( + state=await self._state(db, row), + capture=CaptureResult( + changes=(), sequence=sync.observed_change_sequence, unchanged=0 + ), + ) + result = await self.records._reconcile_scope_locked( + db, + sync, + ReconcileScope( + fence=request.fence, + scope=(await self._state(db, row)).scope, + removal_reason=( + "scope_removed" + if cycle.configuration.fresh_inventory(row.record_type) + else request.removal_reason + ), + observed_at=request.observed_at, + limit=request.limit, + ), + seen_id=row.sweep_id, + parent_scoped=True, + ) + row.revision += 1 + if not result.has_more: + row.phase = "complete" + row.completed_at = datetime.now(timezone.utc) + publish_scope(row, cycle, sync.observed_change_sequence, request.fence.attempt_id) + await db.flush() + return ScanResult(state=await self._state(db, row), capture=result.capture) diff --git a/backend/airweave/domains/entities/canonical/scope_execution.py b/backend/airweave/domains/entities/canonical/scope_execution.py new file mode 100644 index 0000000000..4732fa762d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/scope_execution.py @@ -0,0 +1,57 @@ +"""Immutable scope plans and successful evidence survive page-continuation replacement.""" + +import json +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, field_validator + +from airweave.domains.entities.canonical.cycle_models import CompletionPolicy, ProviderCheckpoint + + +class ScopePlan(BaseModel): + """Effective provider parameters, not original content or a second page cursor.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + mode: Literal["full", "changes"] = "full" + request_context: dict[str, JsonValue] = Field(default_factory=dict) + starting_checkpoint: ProviderCheckpoint | None = None + + @field_validator("request_context") + @classmethod + def bounded(cls, value): + """Bound immutable source parameters independently of provider progress.""" + if len(json.dumps(value, allow_nan=False).encode()) > 65536: + raise ValueError("Scope request context exceeds64KiB") + return value + + +class ScopeEvidence(BaseModel): + """Only SQL completion can issue evidence for this exact owner and request.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + cycle_id: UUID + sweep_id: UUID + completed_at: AwareDatetime + parent_visibility_epoch: int | None + request_context: dict[str, JsonValue] + policy: CompletionPolicy + checkpoint: ProviderCheckpoint | None = None + observed_change_sequence: int | None = Field(default=None, ge=0) + writer_attempt_id: UUID | None = None + + def matches(self, plan: ScopePlan, epoch: int | None) -> bool: + """Revival or changed native request parameters cannot reuse old authority.""" + return ( + self.parent_visibility_epoch == epoch and self.request_context == plan.request_context + ) + + +class ScopeExecution(BaseModel): + """One current plan, with independent previously completed publication and baseline.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[1] = 1 + plan: ScopePlan + last_full: ScopeEvidence | None = None + published: ScopeEvidence | None = None diff --git a/backend/airweave/domains/entities/canonical/search_metadata.py b/backend/airweave/domains/entities/canonical/search_metadata.py new file mode 100644 index 0000000000..d05e1e627c --- /dev/null +++ b/backend/airweave/domains/entities/canonical/search_metadata.py @@ -0,0 +1,30 @@ +"""Canonical-owned search attributes, independent of provider entity date fallbacks.""" + +from datetime import datetime, timezone + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.platform.entities._base import AirweaveSystemMetadata + +SEARCH_METADATA_PIPELINE_VERSION = 2 +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def epoch_microseconds(value: datetime) -> int: + """Preserve source precision without float timestamp rounding.""" + if value.tzinfo is None: + raise ValueError("Canonical search timestamp must be timezone-aware") + delta = value - _EPOCH + return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds + + +def stamp_search_metadata(meta: AirweaveSystemMetadata, record: SourceRecord) -> None: + """Use only the committed source record, never mapper or observation timestamps.""" + meta.canonical_record_type = record.identity.record_type + meta.source_created_known = int(record.source_created_at is not None) + meta.source_updated_known = int(record.source_updated_at is not None) + meta.source_created_us = ( + epoch_microseconds(record.source_created_at) if record.source_created_at else None + ) + meta.source_updated_us = ( + epoch_microseconds(record.source_updated_at) if record.source_updated_at else None + ) diff --git a/backend/airweave/domains/entities/canonical/service.py b/backend/airweave/domains/entities/canonical/service.py new file mode 100644 index 0000000000..5cdaf16276 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/service.py @@ -0,0 +1,210 @@ +"""Transaction boundary for source capture; external I/O happens before entry.""" + +from uuid import UUID + +from pydantic import JsonValue +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CaptureCycle, + CompleteCycle, + RestartCycle, + ScopeWork, +) +from airweave.domains.entities.canonical.cycle_store import ( + begin_cycle, + complete_cycle, + cursor_row, + cycle_state, + next_scope_work, + restart_cycle, +) +from airweave.domains.entities.canonical.models import CaptureResult, ReconcileResult, SourceRecord +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CompletedScope, + ReconcileScope, + RemovedScope, + ScopeRemovalReason, + StartedScope, + WriterFence, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitOmission, + CommitScanPage, + ReconcileScan, + ScanAdmission, + ScanResult, + ScanState, +) +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore + + +class CanonicalCaptureService: + """Own capture transactions; callers supply a fresh session without pending writes.""" + + def __init__(self, store: CanonicalRecordStore): + """Inject the persistence owner without hidden global sessions.""" + self.store = store + self.scans = CanonicalScanStore(store) + + async def activate_writer( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + job_id: UUID, + *, + attempt_id: UUID, + attempt_number: int, + ) -> WriterFence: + """Activate one valid source run under the same lock used for capture.""" + async with UnitOfWork(db): + return await self.store.activate_writer( + db, + organization_id, + sync_id, + job_id, + attempt_id=attempt_id, + attempt_number=attempt_number, + ) + + async def capture(self, db: AsyncSession, batch: CaptureBatch) -> CaptureResult: + """Commit record state and observed changes atomically.""" + async with UnitOfWork(db): + return await self.store.capture(db, batch) + + async def reconcile_scope(self, db: AsyncSession, request: ReconcileScope) -> ReconcileResult: + """Commit one bounded exact-scope absence reconciliation batch.""" + async with UnitOfWork(db): + return await self.store.reconcile_scope(db, request) + + async def start_scope(self, db: AsyncSession, fence: WriterFence, scope: StartedScope) -> None: + """Commit a new exact-scope enumeration boundary.""" + async with UnitOfWork(db): + await self.store.start_scope(db, fence, scope) + + async def remove_scope( + self, + db: AsyncSession, + fence: WriterFence, + scope: RemovedScope, + *, + limit: int = 250, + ) -> ReconcileResult: + """Commit a bounded batch of confirmed inaccessible/deselected records.""" + async with UnitOfWork(db): + return await self.store.remove_scope(db, fence, scope, limit=limit) + + async def reconcile_parents( + self, + db: AsyncSession, + fence: WriterFence, + *, + limit: int = 250, + ) -> ReconcileResult: + """Commit bounded durable removal of children with inaccessible/missing parents.""" + async with UnitOfWork(db): + return await self.store.reconcile_parents(db, fence, limit=limit) + + async def save_checkpoint( + self, db: AsyncSession, fence: WriterFence, cursor_data: dict[str, JsonValue] + ) -> None: + """Advance only after the caller's full capture/reconciliation completion barrier.""" + async with UnitOfWork(db): + await self.store.save_checkpoint(db, fence, cursor_data) + + async def next_scope_work( + self, + db: AsyncSession, + fence: WriterFence, + cycle_id: UUID, + *, + within: CompletedScope | None = None, + ) -> ScopeWork | None: + """Read the next eligible incomplete scope under the existing writer fence.""" + async with UnitOfWork(db): + await self.store._fenced_sync(db, fence) + return await next_scope_work(db, fence, cycle_id, within=within) + + async def withdraw_scan_parent( + self, + db: AsyncSession, + fence: WriterFence, + parent: SourceRecord, + *, + expected_epoch: int, + removal_reason: ScopeRemovalReason, + ) -> CaptureResult: + """Attest exact fetched routing revision and owner epoch in the withdrawal transaction.""" + async with UnitOfWork(db): + return await self.scans.withdraw_parent( + db, fence, parent, expected_epoch=expected_epoch, removal_reason=removal_reason + ) + + async def scan_missing( + self, db: AsyncSession, fence: WriterFence, state: ScanState, *, after: UUID | None = None + ) -> tuple[SourceRecord, ...]: + """Read exact inventory omissions under the same attempt and scan revision.""" + async with UnitOfWork(db): + return await self.scans.missing(db, fence, state, after=after) + + async def read_scan( + self, db: AsyncSession, fence: WriterFence, scope: CompletedScope + ) -> ScanState | None: + """Reload an exact scope after restart or uncertain acknowledgement.""" + async with UnitOfWork(db): + return await self.scans.read(db, fence, scope) + + async def begin_scan(self, db: AsyncSession, request: BeginScan) -> ScanState: + """Begin/resume under the same fence as captured records.""" + async with UnitOfWork(db): + return await self.scans.begin(db, request) + + async def admit_scan(self, db: AsyncSession, request: BeginScan) -> ScanAdmission: + """Exact owner capture and child admission commit or roll back together.""" + async with UnitOfWork(db): + return await self.scans.admit(db, request) + + async def commit_scan_page(self, db: AsyncSession, request: CommitScanPage) -> ScanResult: + """Commit original records and their continuation atomically.""" + async with UnitOfWork(db): + return await self.scans.page(db, request) + + async def commit_omission(self, db: AsyncSession, request: CommitOmission) -> ScanResult: + """Accept exact provider validation without network I/O in the transaction.""" + async with UnitOfWork(db): + return await self.scans.omission(db, request) + + async def reconcile_scan(self, db: AsyncSession, request: ReconcileScan) -> ScanResult: + """Commit one bounded whole-scope absence reconciliation step.""" + async with UnitOfWork(db): + return await self.scans.reconcile(db, request) + + async def read_cycle(self, db: AsyncSession, fence: WriterFence) -> CaptureCycle | None: + """Read the current durable boundary without creating a new cycle.""" + async with UnitOfWork(db): + await self.store._fenced_sync(db, fence) + return cycle_state(await cursor_row(db, fence)) + + async def begin_cycle(self, db: AsyncSession, request: BeginCycle) -> CaptureCycle: + """Resume or explicitly advance the existing checkpoint's cycle.""" + async with UnitOfWork(db): + await self.store._fenced_sync(db, request.fence) + return await begin_cycle(db, request) + + async def complete_cycle(self, db: AsyncSession, request: CompleteCycle) -> CaptureCycle: + """Atomically verify scope completion and publish the source checkpoint.""" + async with UnitOfWork(db): + sync = await self.store._fenced_sync(db, request.fence) + return await complete_cycle(db, sync, request) + + async def restart_cycle(self, db: AsyncSession, request: RestartCycle) -> CaptureCycle: + """Explicitly replace active cycle state under the writer fence and exact CAS.""" + async with UnitOfWork(db): + await self.store._fenced_sync(db, request.fence) + return await restart_cycle(db, request) diff --git a/backend/airweave/domains/entities/canonical/slack_files.py b/backend/airweave/domains/entities/canonical/slack_files.py new file mode 100644 index 0000000000..f38c87af82 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/slack_files.py @@ -0,0 +1,46 @@ +"""Retained acquisition evidence for files owned by a Slack message.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +SlackFileReason = Literal[ + "access_denied", "unsupported_external", "oversized", "missing_metadata", "not_found" +] + + +class SlackFileOutcome(BaseModel): + """One native file position, without modifying the message's native JSON.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + index: int = Field(ge=0, lt=100) + native_id: str = Field(pattern=r"^F[A-Z0-9]+$", max_length=128) + outcome: Literal["captured", "unavailable"] + reason: SlackFileReason | None = None + file: dict[str, JsonValue] | None = None + + @model_validator(mode="after") + def consistent(self): + """Retained enrichment must attest the same native file.""" + if (self.outcome == "captured") != (self.reason is None): + raise ValueError("Slack file outcome and reason disagree") + if self.file is not None and self.file.get("id") != self.native_id: + raise ValueError("Slack file enrichment identity disagrees") + return self + + +class SlackFileManifest(BaseModel): + """Bounded, ordered per-message acquisition results.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[1] = 1 + files: tuple[SlackFileOutcome, ...] = Field(max_length=100) + + @model_validator(mode="after") + def positions(self): + """No duplicate identities or omitted native positions.""" + if [entry.index for entry in self.files] != list(range(len(self.files))): + raise ValueError("Slack file positions are not contiguous") + if len({entry.native_id for entry in self.files}) != len(self.files): + raise ValueError("Slack file identities are duplicated") + return self diff --git a/backend/airweave/domains/entities/canonical/slack_projection.py b/backend/airweave/domains/entities/canonical/slack_projection.py new file mode 100644 index 0000000000..311fab3e54 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/slack_projection.py @@ -0,0 +1,211 @@ +"""Offline Slack file inputs from retained message JSON and account-owned blobs.""" + +import mimetypes +import re +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from airweave.domains.entities.canonical.blob_materializer import read_blob, write_blob +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.entities.canonical.requests import BlobReference, parent_container_key +from airweave.domains.entities.canonical.slack_files import SlackFileManifest +from airweave.domains.storage.protocols import StorageBackend +from airweave.domains.sync_pipeline.pipeline.text_models import NativeTextBody +from airweave.domains.sync_pipeline.processors.entity_fields import populate_base_fields +from airweave.platform.entities._base import BaseEntity, Breadcrumb +from airweave.platform.entities.slack import SlackAttachmentEntity + + +class SlackFile(BaseModel): + """Only projection fields are parsed; the retained native payload stays unchanged.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(pattern=r"^F[A-Z0-9]+$", max_length=128) + name: str | None = Field(default=None, max_length=4096) + mimetype: str | None = Field(default=None, max_length=256) + + +class SlackFiles(BaseModel): + """A missing file list means no attachments; malformed lists never mean empty.""" + + model_config = ConfigDict(extra="ignore", strict=True) + files: list[SlackFile] = Field(default_factory=list) + + +async def _file_inventory( + record: SourceRecord, storage: StorageBackend +) -> tuple[list[SlackFile], SlackFileManifest | None]: + """Verify optional acquisition evidence against unchanged native file positions.""" + if record.payload_schema_version not in (1, 2): + raise ValueError("Unsupported Slack message capture schema") + if record.payload_schema_version == 2 and record.blobs: + raise ValueError("Child-owned Slack message cannot retain inline file blobs") + try: + files = SlackFiles.model_validate(record.payload).files + except ValidationError: + raise ValueError("Slack file metadata is malformed") from None + if len({file.id for file in files}) != len(files): + raise ValueError("Slack message contains duplicate native file identities") + manifests = [blob for blob in record.blobs if blob.role == "representation_manifest"] + if len(manifests) > 1: + raise ValueError("Slack message has multiple file acquisition manifests") + manifest = None + if manifests: + content = await read_blob(record, manifests[0], storage) + try: + manifest = SlackFileManifest.model_validate_json(content) + if [entry.native_id for entry in manifest.files] != [file.id for file in files]: + raise ValueError("Slack file manifest does not match native message identities") + files = [ + SlackFile.model_validate(entry.file) if entry.file is not None else file + for file, entry in zip(files, manifest.files, strict=True) + ] + except ValidationError: + raise ValueError("Slack file acquisition manifest is malformed") from None + paths = {f"/files/{index}" for index in range(len(files))} + if any(blob.role is None and blob.source_path not in paths for blob in record.blobs): + raise ValueError("Slack blob does not identify a current message file") + return files, manifest + + +async def map_slack_files( + record: SourceRecord, body: BaseEntity, storage: StorageBackend, directory: Path +) -> ProjectionInputs: + """Retain one body and one expected part per file, including uncaptured originals.""" + files, manifest = await _file_inventory(record, storage) + populate_base_fields(body) + native_body = None + if "text" in record.payload: + native_body = NativeTextBody(text=record.payload["text"], metadata_fields=("text",)) + parts = [ + ProjectionInput( + part=ExtractionPart(part_index=0, key="body", kind="body"), + entity=body, + native_body=native_body, + ) + ] + if record.payload_schema_version == 2: + return ProjectionInputs(parts=tuple(parts)) + for index, file in enumerate(files): + filename = file.name or file.id + suffix = Path(filename).suffix.lower() + if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix): + suffix = mimetypes.guess_extension(file.mimetype or "") or ".bin" + descriptor = ExtractionPart( + part_index=index + 1, + key=f"file:{file.id}", + kind="file", + media_type=file.mimetype, + extension=suffix, + ) + references = [blob for blob in record.blobs if blob.source_path == f"/files/{index}"] + if len(references) > 1: + raise ValueError("Slack file has ambiguous original blob references") + if manifest is not None: + outcome = manifest.files[index] + if (outcome.outcome == "captured") != bool(references): + raise ValueError("Slack file manifest contradicts retained original blobs") + if not references: + if record.completeness == "complete": + raise ValueError("Complete Slack message lacks a retained file") + parts.append(ProjectionInput(part=descriptor, entity=None)) + continue + entity = await _materialize_file( + record, + file, + references[0], + storage, + directory, + suffix, + parent_id=body.entity_id, + ) + parts.append(ProjectionInput(part=descriptor, entity=entity)) + return ProjectionInputs(parts=tuple(parts)) + + +async def _materialize_file( + record: SourceRecord, + file: SlackFile, + reference: BlobReference, + storage: StorageBackend, + directory: Path, + suffix: str, + *, + parent_id: str, +) -> SlackAttachmentEntity: + """Verify only this record's bytes and materialize a disposable converter input.""" + content = await read_blob(record, reference, storage) + path = await write_blob(content, directory, suffix=suffix) + entity = SlackAttachmentEntity( + attachment_key=f"{record.identity.container_id}:{record.identity.native_id}:{file.id}", + filename=file.name or file.id, + breadcrumbs=[ + Breadcrumb(entity_id=parent_id, name="Slack message", entity_type="SlackMessageEntity") + ], + url="", + size=len(content), + file_type=suffix.lstrip("."), + mime_type=file.mimetype, + local_path=str(path), + ) + populate_base_fields(entity) + return entity + + +async def map_slack_file( + record: SourceRecord, storage: StorageBackend, directory: Path +) -> ProjectionInputs: + """A durable child is the only extraction owner of its message/file occurrence.""" + parent = record.parent + if ( + record.payload_schema_version != 1 + or parent is None + or parent.record_type != "message" + or not parent.container_id + or record.identity.container_id != parent_container_key(parent) + ): + raise ValueError("Slack file requires an exact message parent") + try: + file = SlackFile.model_validate(record.payload) + except ValidationError: + raise ValueError("Slack child file metadata is malformed") from None + if file.id != record.identity.native_id: + raise ValueError("Slack child native file identity disagrees") + manifests = [blob for blob in record.blobs if blob.role == "representation_manifest"] + references = [blob for blob in record.blobs if blob.role is None] + if ( + len(manifests) != 1 + or len(references) > 1 + or any(blob.source_path != "" for blob in references) + ): + raise ValueError("Slack child requires one acquisition manifest and a root original") + content = await read_blob(record, manifests[0], storage) + try: + manifest = SlackFileManifest.model_validate_json(content) + if len(manifest.files) != 1 or manifest.files[0].native_id != file.id: + raise ValueError("Slack child acquisition manifest identity disagrees") + outcome = manifest.files[0] + if outcome.file is not None: + file = SlackFile.model_validate(outcome.file) + except ValidationError: + raise ValueError("Slack child acquisition manifest is malformed") from None + captured = outcome.outcome == "captured" + if captured != bool(references) or (not captured and record.completeness == "complete"): + raise ValueError("Slack child acquisition evidence contradicts retained bytes") + suffix = Path(file.name or "").suffix.lower() + if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix): + suffix = mimetypes.guess_extension(file.mimetype or "") or ".bin" + descriptor = ExtractionPart( + part_index=0, key=f"file:{file.id}", kind="file", media_type=file.mimetype, extension=suffix + ) + entity = ( + await _materialize_file( + record, file, references[0], storage, directory, suffix, parent_id=parent.native_id + ) + if captured + else None + ) + return ProjectionInputs(parts=(ProjectionInput(part=descriptor, entity=entity),)) diff --git a/backend/airweave/domains/entities/canonical/source.py b/backend/airweave/domains/entities/canonical/source.py new file mode 100644 index 0000000000..c3557387b0 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/source.py @@ -0,0 +1,57 @@ +"""Explicit source capability for original provider observations from one crawl.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RemovedScope, + StartedScope, +) + +if TYPE_CHECKING: + from airweave.domains.browse_tree.types import NodeSelectionData + from airweave.domains.sources.protocols import SourceRegistryProtocol + from airweave.domains.storage.file_service import FileService + from airweave.domains.syncs.cursors.cursor import SyncCursor + +SourceObservation = CaptureRecord | CompletedScope | RemovedScope | StartedScope + + +def indexed_record_types(short_name: str, registry: SourceRegistryProtocol) -> tuple[str, ...]: + """Index capability is independent of provider authentication or crawling. + + Native snapshots use canonical publications but have no provider lifecycle. + This declaration grants no access: callers must still authorize the stored + source connection and validate its current publication. + """ + if short_name == "almanac": + return ("knowledge", "session", "message") + return getattr(registry.get(short_name).source_class_ref, "canonical_record_types", ()) + + +@runtime_checkable +class CanonicalSource(Protocol): + """Opt-in contract; declared types are audited operational records, not search chunks.""" + + canonical_record_types: tuple[str, ...] + + def generate_observations( + self, + *, + cursor: SyncCursor | None = None, + files: FileService | None = None, + node_selections: list[NodeSelectionData] | None = None, + ) -> AsyncGenerator[SourceObservation, None]: + """Yield native records and only successfully completed exact enumeration scopes.""" + ... + + +@runtime_checkable +class ContainerScopedSource(Protocol): + """Audited child record types whose visibility depends on a root container record.""" + + canonical_container_parents: dict[str, str] diff --git a/backend/airweave/domains/entities/canonical/store.py b/backend/airweave/domains/entities/canonical/store.py new file mode 100644 index 0000000000..a5abb34057 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/store.py @@ -0,0 +1,837 @@ +"""SQL persistence for canonical records. Never commits a caller's transaction.""" + +import hashlib +import json +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import and_, any_, exists, func, literal, not_, or_, select, update +from sqlalchemy.dialects.postgresql import array +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from airweave.domains.entities.canonical.checkpoint import CanonicalCheckpoint +from airweave.domains.entities.canonical.cycle_models import CYCLE_KEY +from airweave.domains.entities.canonical.models import ( + CaptureResult, + ChangePage, + ObservedChange, + ReconcileResult, + SourceRecord, +) +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CaptureRecord, + ReconcileScope, + RecordIdentity, + RemovedScope, + StartedScope, + WriterFence, +) +from airweave.models.entity import Entity +from airweave.models.entity_change import EntityChange +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob + + +class CanonicalStoreError(Exception): + """Stable boundary error, translated by the API edge.""" + + code = "canonical_store_error" + + +class SourceNotFound(CanonicalStoreError): + """No source in the authenticated organization.""" + + code = "source_not_found" + + +class StaleWriter(CanonicalStoreError): + """A cancelled or superseded source activity cannot write.""" + + code = "stale_writer" + + +class WriterBusy(CanonicalStoreError): + """Another source run still owns the capture writer.""" + + code = "writer_busy" + + +def _require_current_generation(sync: Sync, job: SyncJob) -> None: + """A prior credential or unverified connection cannot acquire or use a writer.""" + generation = sync.provisioning_generation + if job.provisioning_generation != generation or ( + generation > 0 + and (sync.provisioning_ready_generation != generation or sync.status != "active") + ): + raise StaleWriter("Source connection generation is not admitted for capture") + + +def capture_fingerprint(record: CaptureRecord) -> str: + """Observation times never create changes; sparse tombstone payloads do.""" + material = record.model_dump( + mode="json", exclude={"observed_at", "allow_reparent", "descendant_visibility_fields"} + ) + serialized = json.dumps( + material, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + ) + return hashlib.sha256(serialized.encode()).hexdigest() + + +def source_record(entity: Entity) -> SourceRecord: + """Canonical rows only; legacy metadata must be recaptured before reading.""" + return SourceRecord( + id=entity.id, + sync_id=entity.sync_id, + identity=RecordIdentity( + record_type=entity.entity_definition_short_name, + native_id=entity.native_id, + container_id=entity.container_id, + ), + parent=( + RecordIdentity( + record_type=entity.parent_record_type, + native_id=entity.parent_native_id, + container_id=entity.parent_container_id, + ) + if entity.parent_record_type is not None + else None + ), + revision=entity.record_revision, + payload=entity.source_payload, + payload_schema_version=entity.payload_schema_version, + capture_hash=entity.capture_hash, + content_hash=entity.content_hash, + completeness=entity.completeness, + observed_at=entity.observed_at, + source_created_at=entity.source_created_at, + source_updated_at=entity.source_updated_at, + deleted_at=entity.deleted_at, + removal_reason=entity.removal_reason, + blobs=entity.blob_references or (), + indexed_revision=entity.indexed_revision, + indexed_pipeline_version=entity.indexed_pipeline_version, + ) + + +def ancestor_chain(subject=Entity): + """Finite scoped ancestor walk; repeated UUIDs stop corrupt record cycles.""" + parent = aliased(Entity) + available = and_( + parent.record_revision > 0, + parent.deleted_at.is_(None), + or_( + parent.removal_reason.is_(None), + parent.removal_reason.not_in(("scope_removed", "access_revoked")), + ), + ) + chain = ( + select( + parent.id, + parent.organization_id, + parent.sync_id, + parent.parent_record_type, + parent.parent_native_id, + parent.parent_container_id, + parent.parent_visibility_epoch, + parent.removal_reason, + array([parent.id]).label("path"), + literal(False).label("cycle"), + and_(available, parent.visibility_epoch == subject.parent_visibility_epoch).label( + "valid" + ), + ) + .where( + parent.organization_id == subject.organization_id, + parent.sync_id == subject.sync_id, + parent.entity_definition_short_name == subject.parent_record_type, + parent.native_id == subject.parent_native_id, + parent.container_id.is_not_distinct_from(subject.parent_container_id), + ) + .correlate(subject) + .cte(recursive=True, nesting=True) + ) + return chain.union_all( + select( + parent.id, + parent.organization_id, + parent.sync_id, + parent.parent_record_type, + parent.parent_native_id, + parent.parent_container_id, + parent.parent_visibility_epoch, + parent.removal_reason, + chain.c.path + array([parent.id]), + (parent.id == any_(chain.c.path)).label("cycle"), + and_( + chain.c.valid, available, parent.visibility_epoch == chain.c.parent_visibility_epoch + ), + ) + .join( + chain, + and_( + parent.organization_id == chain.c.organization_id, + parent.sync_id == chain.c.sync_id, + parent.entity_definition_short_name == chain.c.parent_record_type, + parent.native_id == chain.c.parent_native_id, + parent.container_id.is_not_distinct_from(chain.c.parent_container_id), + ), + ) + .where(not_(chain.c.cycle)) + ) + + +def active_parent_exists(): + """All parent attestations must reach an available root without a record cycle.""" + chain = ancestor_chain() + return exists( + select(chain.c.id).where( + chain.c.parent_record_type.is_(None), chain.c.valid, not_(chain.c.cycle) + ) + ).correlate(Entity) + + +def parent_is_visible(): + """Deny children of missing/deleted containers immediately, before cleanup catches up.""" + return or_(Entity.parent_record_type.is_(None), active_parent_exists()) + + +def content_is_available(): + """Shared SQL gate for read, historical payload access and search postvalidation.""" + return and_( + parent_is_visible(), + or_( + Entity.removal_reason.is_(None), + Entity.removal_reason.not_in(("scope_removed", "access_revoked")), + ), + ) + + +def with_content_access(record: SourceRecord, available: bool) -> SourceRecord: + """Retain citation identity/version while explicitly withholding revoked content.""" + if available: + return record + return record.model_copy(update={"content_access": "unavailable", "payload": {}, "blobs": ()}) + + +class CanonicalRecordStore: + """One short Sync row lock serializes commits and allocates journal positions.""" + + async def _sync( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, *, lock: bool = False + ) -> Sync: + statement = select(Sync).where(Sync.id == sync_id, Sync.organization_id == organization_id) + if lock: + statement = statement.with_for_update().execution_options(populate_existing=True) + sync = (await db.execute(statement)).scalar_one_or_none() + if sync is None: + raise SourceNotFound("Source does not exist in this organization") + return sync + + async def admit_job( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, job_id: UUID + ) -> int: + """Read one current job/source generation before external source construction. + + This is an admission snapshot, not a replacement for the locked write fence. + Callers compare it again after construction; every write rechecks independently. + """ + row = ( + await db.execute( + select(Sync, SyncJob) + .join(SyncJob, SyncJob.sync_id == Sync.id) + .where( + Sync.id == sync_id, + Sync.organization_id == organization_id, + SyncJob.id == job_id, + SyncJob.organization_id == organization_id, + ) + .execution_options(populate_existing=True) + ) + ).one_or_none() + if row is None: + raise StaleWriter("Source job is unavailable for capture") + sync, job = row + if job.status not in ("pending", "running"): + raise StaleWriter("Source run is no longer active") + _require_current_generation(sync, job) + return sync.provisioning_generation + + async def activate_writer( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + job_id: UUID, + *, + attempt_id: UUID, + attempt_number: int, + ) -> WriterFence: + """Claim a pending/running job; an identical activation is idempotent.""" + if attempt_number < 1: + raise ValueError("attempt_number must be positive") + sync = await self._sync(db, organization_id, sync_id, lock=True) + job = ( + await db.execute( + select(SyncJob) + .where( + SyncJob.id == job_id, + SyncJob.sync_id == sync_id, + SyncJob.organization_id == organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + if job is None or job.status not in ("pending", "running"): + raise StaleWriter("Writer must be an active job of this source") + _require_current_generation(sync, job) + identical = ( + sync.writer_job_id == job_id + and sync.writer_attempt_id == attempt_id + and sync.writer_attempt_number == attempt_number + ) + if sync.writer_job_id == job_id and not identical: + if attempt_number <= sync.writer_attempt_number: + raise StaleWriter("Attempt has been superseded or conflicts with current attempt") + if sync.writer_job_id != job_id: + if sync.writer_job_id is not None: + status = await db.scalar( + select(SyncJob.status).where(SyncJob.id == sync.writer_job_id) + ) + if status in ("pending", "running", "cancelling"): + raise WriterBusy("Another source run still owns capture") + if not identical: + sync.writer_epoch += 1 + sync.writer_job_id = job_id + sync.writer_attempt_id = attempt_id + sync.writer_attempt_number = attempt_number + await db.flush() + return WriterFence( + organization_id=organization_id, + sync_id=sync_id, + job_id=job_id, + epoch=sync.writer_epoch, + attempt_id=attempt_id, + attempt_number=attempt_number, + ) + + async def _fenced_sync(self, db: AsyncSession, fence: WriterFence) -> Sync: + sync = await self._sync(db, fence.organization_id, fence.sync_id, lock=True) + if ( + sync.writer_epoch != fence.epoch + or sync.writer_job_id != fence.job_id + or sync.writer_attempt_id != fence.attempt_id + or sync.writer_attempt_number != fence.attempt_number + ): + raise StaleWriter("Source run has been superseded") + job = await db.scalar( + select(SyncJob) + .where( + SyncJob.id == fence.job_id, + SyncJob.sync_id == fence.sync_id, + SyncJob.organization_id == fence.organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if job is None or job.status not in ("pending", "running"): + raise StaleWriter("Source run is no longer active") + _require_current_generation(sync, job) + return sync + + async def capture(self, db: AsyncSession, batch: CaptureBatch) -> CaptureResult: + """Apply observations and append snapshots under the same writer lock.""" + sync = await self._fenced_sync(db, batch.fence) + return await self._capture_locked(db, sync, batch) + + async def _parent_attestation( + self, db: AsyncSession, sync: Sync, observation: CaptureRecord, entity: Entity | None + ) -> int | None: + """Resolve provider parent identity only inside the source writer transaction.""" + if observation.parent is None: + return None + parent_identity = observation.parent + if observation.identity == parent_identity: + raise CanonicalStoreError("A record cannot parent itself") + parent = await db.scalar( + select(Entity).where( + Entity.organization_id == sync.organization_id, + Entity.sync_id == sync.id, + Entity.entity_definition_short_name == parent_identity.record_type, + Entity.entity_id == parent_identity.entity_key, + Entity.record_revision > 0, + ) + ) + if observation.kind == "delete" and parent is None: + return None + if parent is None: + raise CanonicalStoreError("Capture parent must exist before its child") + if entity is not None: + chain = ancestor_chain() + contains_child = exists(select(chain.c.id).where(chain.c.id == entity.id)).correlate( + Entity + ) + cycles = await db.scalar(select(contains_child).where(Entity.id == parent.id)) + if cycles: + raise CanonicalStoreError("Record parent would create a cycle") + if observation.kind == "delete" and entity is not None: + return entity.parent_visibility_epoch + available = await db.scalar(select(content_is_available()).where(Entity.id == parent.id)) + if parent.deleted_at is not None or not available: + if observation.kind == "delete": + return None + raise CanonicalStoreError("Capture parent is unavailable") + return parent.visibility_epoch + + async def _capture_locked( + self, + db: AsyncSession, + sync: Sync, + batch: CaptureBatch, + *, + seen_id: UUID | None = None, + mark_seen: bool = True, + ) -> CaptureResult: + changes = [] + unchanged = 0 + for observation in batch.records: + identity = observation.identity + entity = ( + await db.execute( + select(Entity) + .where( + Entity.organization_id == batch.fence.organization_id, + Entity.sync_id == sync.id, + Entity.entity_id == identity.entity_key, + Entity.entity_definition_short_name == identity.record_type, + ) + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + if entity is not None and observation.kind == "delete" and observation.parent is None: + # Native deletion feeds may contain only an object ID. Missing parent data + # is not evidence that the object moved out of its known authorization chain. + observation = observation.model_copy( + update={"parent": source_record(entity).parent} + ) + parent_epoch = await self._parent_attestation(db, sync, observation, entity) + fingerprint = capture_fingerprint(observation) + was_available = ( + (await db.scalar(select(content_is_available()).where(Entity.id == entity.id))) + if entity is not None + else True + ) + was_deleted = entity is not None and entity.deleted_at is not None + parent_changed = entity is not None and ( + (entity.parent_record_type, entity.parent_native_id, entity.parent_container_id) + != ( + ( + observation.parent.record_type, + observation.parent.native_id, + observation.parent.container_id, + ) + if observation.parent + else (None, None, None) + ) + ) + if parent_changed and not observation.allow_reparent: + raise CanonicalStoreError( + "Existing identity belongs to a different parent; " + "explicit provider move required" + ) + attestation_changed = ( + entity is not None and entity.parent_visibility_epoch != parent_epoch + ) + access_changed = ( + entity is not None + and bool(observation.descendant_visibility_fields) + and ( + entity.source_payload is None + or any( + (key in entity.source_payload, entity.source_payload.get(key)) + != (key in observation.payload, observation.payload.get(key)) + for key in observation.descendant_visibility_fields + ) + ) + ) + if ( + entity is not None + and entity.capture_hash == fingerprint + and not attestation_changed + and not access_changed + and not (observation.kind == "upsert" and not was_available) + ): + if mark_seen: + entity.last_seen_run_id = seen_id or batch.fence.attempt_id + entity.observed_at = observation.observed_at + unchanged += 1 + continue + if entity is None: + entity = Entity( + id=uuid4(), + organization_id=batch.fence.organization_id, + sync_id=sync.id, + entity_id=identity.entity_key, + entity_definition_short_name=identity.record_type, + record_revision=0, + visibility_epoch=1, + ) + db.add(entity) + if observation.kind == "upsert" and ( + was_deleted or not was_available or parent_changed or access_changed + ): + entity.visibility_epoch += 1 + entity.parent_visibility_epoch = parent_epoch + entity.sync_job_id = batch.fence.job_id + entity.native_id = identity.native_id + entity.container_id = identity.container_id + entity.parent_record_type = ( + observation.parent.record_type if observation.parent else None + ) + entity.parent_native_id = observation.parent.native_id if observation.parent else None + entity.parent_container_id = ( + observation.parent.container_id if observation.parent else None + ) + entity.source_payload = observation.payload + entity.payload_schema_version = observation.payload_schema_version + entity.record_revision += 1 + entity.capture_hash = fingerprint + entity.content_hash = observation.content_hash + entity.hash = observation.content_hash or fingerprint + entity.source_created_at = observation.source_created_at + entity.source_updated_at = observation.source_updated_at + entity.observed_at = observation.observed_at + entity.deleted_at = observation.observed_at if observation.kind == "delete" else None + entity.removal_reason = observation.removal_reason + entity.completeness = observation.completeness + entity.blob_references = [blob.model_dump(mode="json") for blob in observation.blobs] + if mark_seen: + entity.last_seen_run_id = seen_id or batch.fence.attempt_id + entity.projection_error = None + # Flush before reading default/index state and before another observation of this ID. + await db.flush() + sync.observed_change_sequence += 1 + record = source_record(entity) + change = ObservedChange( + sequence=sync.observed_change_sequence, kind=observation.kind, record=record + ) + db.add( + EntityChange( + organization_id=batch.fence.organization_id, + sync_id=sync.id, + entity_record_id=entity.id, + sequence=change.sequence, + record_revision=record.revision, + kind=observation.kind, + snapshot=record.model_dump(mode="json"), + ) + ) + changes.append(change) + await db.flush() + return CaptureResult( + changes=tuple(changes), sequence=sync.observed_change_sequence, unchanged=unchanged + ) + + async def reconcile_scope(self, db: AsyncSession, request: ReconcileScope) -> ReconcileResult: + """Tombstone a bounded batch absent from one successfully enumerated scope.""" + sync = await self._fenced_sync(db, request.fence) + return await self._reconcile_scope_locked(db, sync, request) + + async def _reconcile_scope_locked( + self, + db: AsyncSession, + sync: Sync, + request: ReconcileScope, + *, + seen_id: UUID | None = None, + parent_scoped: bool = False, + ) -> ReconcileResult: + """Share exact-scope reconciliation with durable whole-scope scans.""" + statement = ( + select(Entity) + .where( + Entity.organization_id == request.fence.organization_id, + Entity.sync_id == sync.id, + Entity.entity_definition_short_name == request.scope.record_type, + Entity.container_id.is_not_distinct_from(request.scope.container_id), + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + Entity.last_seen_run_id.is_distinct_from(seen_id or request.fence.attempt_id), + ) + .order_by(Entity.id) + .limit(request.limit + 1) + ) + if parent_scoped: + parent = request.scope.parent + statement = statement.where( + Entity.parent_record_type.is_not_distinct_from( + parent.record_type if parent else None + ), + Entity.parent_native_id.is_not_distinct_from(parent.native_id if parent else None), + Entity.parent_container_id.is_not_distinct_from( + parent.container_id if parent else None + ), + ) + entities = list((await db.scalars(statement)).all()) + observations = tuple( + CaptureRecord( + identity=source_record(entity).identity, + parent=source_record(entity).parent, + payload=entity.source_payload, + payload_schema_version=entity.payload_schema_version, + kind="delete", + removal_reason=request.removal_reason, + completeness=entity.completeness, + content_hash=entity.content_hash, + source_created_at=entity.source_created_at, + source_updated_at=entity.source_updated_at, + observed_at=request.observed_at, + blobs=entity.blob_references or (), + ) + for entity in entities[: request.limit] + ) + result = await self._capture_locked( + db, sync, CaptureBatch(fence=request.fence, records=observations), seen_id=seen_id + ) + return ReconcileResult(capture=result, has_more=len(entities) > request.limit) + + async def start_scope(self, db: AsyncSession, fence: WriterFence, scope: StartedScope) -> None: + """Restart a full-scan scope without inheriting partial incremental sightings.""" + await self._fenced_sync(db, fence) + await db.execute( + update(Entity) + .where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name == scope.record_type, + Entity.container_id.is_not_distinct_from(scope.container_id), + Entity.record_revision > 0, + ) + .values(last_seen_run_id=None) + ) + await db.flush() + + async def remove_scope( + self, + db: AsyncSession, + fence: WriterFence, + scope: RemovedScope, + *, + limit: int = 250, + ) -> ReconcileResult: + """Tombstone a bounded exact scope regardless of this attempt's sightings.""" + if not 1 <= limit <= 500: + raise ValueError("Scope removal batch limit must be between 1 and 500") + sync = await self._fenced_sync(db, fence) + entities = list( + ( + await db.scalars( + select(Entity) + .where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name == scope.record_type, + Entity.container_id.is_not_distinct_from(scope.container_id), + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + ) + .order_by(Entity.id) + .limit(limit + 1) + ) + ).all() + ) + observations = tuple( + CaptureRecord( + identity=source_record(entity).identity, + parent=source_record(entity).parent, + payload=entity.source_payload, + payload_schema_version=entity.payload_schema_version, + kind="delete", + removal_reason=scope.removal_reason, + completeness=entity.completeness, + content_hash=entity.content_hash, + source_created_at=entity.source_created_at, + source_updated_at=entity.source_updated_at, + observed_at=scope.observed_at, + blobs=entity.blob_references or (), + ) + for entity in entities[:limit] + ) + result = await self._capture_locked( + db, sync, CaptureBatch(fence=fence, records=observations) + ) + return ReconcileResult(capture=result, has_more=len(entities) > limit) + + async def reconcile_parents( + self, + db: AsyncSession, + fence: WriterFence, + *, + limit: int = 250, + ) -> ReconcileResult: + """Durable unfinished removal is derived from child rows, not transient events.""" + if not 1 <= limit <= 500: + raise ValueError("Parent reconciliation limit must be between 1 and 500") + sync = await self._fenced_sync(db, fence) + rows = list( + ( + await db.scalars( + select(Entity) + .where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + Entity.parent_record_type.is_not(None), + ~active_parent_exists(), + ) + .order_by(Entity.id) + .limit(limit + 1) + ) + ).all() + ) + observations = [] + for entity in rows[:limit]: + record = source_record(entity) + chain = ancestor_chain() + nearest_reason = ( + select(chain.c.removal_reason) + .where(chain.c.removal_reason.in_(("access_revoked", "scope_removed"))) + .order_by(func.cardinality(chain.c.path)) + .limit(1) + .scalar_subquery() + ) + reason = await db.scalar(select(nearest_reason).where(Entity.id == entity.id)) + observations.append( + CaptureRecord( + identity=record.identity, + parent=record.parent, + payload=record.payload, + payload_schema_version=record.payload_schema_version, + kind="delete", + removal_reason="access_revoked" + if reason == "access_revoked" + else "scope_removed", + completeness=record.completeness, + content_hash=record.content_hash, + source_created_at=record.source_created_at, + source_updated_at=record.source_updated_at, + observed_at=datetime.now(timezone.utc), + blobs=record.blobs, + ) + ) + result = await self._capture_locked( + db, sync, CaptureBatch(fence=fence, records=tuple(observations)) + ) + return ReconcileResult(capture=result, has_more=len(rows) > limit) + + async def save_checkpoint( + self, db: AsyncSession, fence: WriterFence, cursor_data: dict + ) -> None: + """Call only after capture barrier and successful exact-scope reconciliation.""" + sync = await self._fenced_sync(db, fence) + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == fence.sync_id)) + if CYCLE_KEY in cursor_data or (cursor is not None and CYCLE_KEY in cursor.cursor_data): + raise CanonicalStoreError("Cycle-owned progress must use explicit cycle finalization") + if cursor is None: + cursor = SyncCursor( + organization_id=fence.organization_id, + sync_id=fence.sync_id, + ) + db.add(cursor) + # Source-supplied or previously loaded values cannot overwrite this reserved stamp. + cursor.cursor_data = { + **cursor_data, + "canonical_checkpoint": CanonicalCheckpoint( + writer_attempt_id=fence.attempt_id, + observed_change_sequence=sync.observed_change_sequence, + ).model_dump(mode="json"), + } + cursor.last_updated = datetime.now(timezone.utc) + await db.flush() + + async def read( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, record_id: UUID + ) -> SourceRecord | None: + """Return current state including tombstones; never return another tenant's row.""" + entity = await db.scalar( + select(Entity).where( + Entity.id == record_id, + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + ) + ) + if entity is None: + return None + available = await db.scalar(select(content_is_available()).where(Entity.id == entity.id)) + return with_content_access(source_record(entity), bool(available)) + + async def changes( + self, + db: AsyncSession, + organization_id: UUID, + sync_id: UUID, + *, + after: int = 0, + limit: int = 100, + high_watermark: int | None = None, + ) -> ChangePage: + """Read immutable snapshots, bounded by a committed per-sync watermark.""" + if after < 0 or not 1 <= limit <= 500: + raise ValueError("Invalid change page bounds") + sync = await self._sync(db, organization_id, sync_id) + upper = sync.observed_change_sequence if high_watermark is None else high_watermark + if upper < after or upper > sync.observed_change_sequence: + raise ValueError("Invalid change high watermark") + rows = list( + ( + await db.scalars( + select(EntityChange) + .where( + EntityChange.organization_id == organization_id, + EntityChange.sync_id == sync_id, + EntityChange.sequence > after, + EntityChange.sequence <= upper, + ) + .order_by(EntityChange.sequence) + .limit(limit + 1) + ) + ).all() + ) + page = rows[:limit] + more = len(rows) > limit + availability = ( + dict( + ( + await db.execute( + select(Entity.id, content_is_available()).where( + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.id.in_([row.entity_record_id for row in page]), + ) + ) + ).all() + ) + if page + else {} + ) + return ChangePage( + changes=tuple( + ObservedChange( + sequence=row.sequence, + kind=row.kind, + record=with_content_access( + SourceRecord.model_validate(row.snapshot), + bool(availability.get(row.entity_record_id, False)), + ), + ) + for row in page + ), + next_sequence=page[-1].sequence if more else upper, + high_watermark=upper, + has_more=more, + ) diff --git a/backend/airweave/domains/entities/canonical/stripe_projection.py b/backend/airweave/domains/entities/canonical/stripe_projection.py new file mode 100644 index 0000000000..0ed651531a --- /dev/null +++ b/backend/airweave/domains/entities/canonical/stripe_projection.py @@ -0,0 +1,316 @@ +"""Offline allowlisted Stripe views; amounts retain native currency units. + +No calculations, expanded-object recursion, secret-bearing metadata, or provider +fetches. Event objects remain historical events, never current financial records. +""" + +from datetime import datetime, timezone +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.platform.entities._base import BaseEntity +from airweave.platform.entities.stripe import ( + StripeBalanceEntity, + StripeBalanceTransactionEntity, + StripeChargeEntity, + StripeCustomerEntity, + StripeEventEntity, + StripeInvoiceEntity, + StripePaymentIntentEntity, + StripePaymentMethodEntity, + StripePayoutEntity, + StripeRefundEntity, + StripeSubscriptionEntity, +) + +Timestamp = Annotated[int, Field(ge=0, le=253402300799)] + + +class _Native(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + +class _Reference(_Native): + id: str + + +class _Money(_Native): + amount: int + currency: str + + +class _Billing(_Native): + name: str | None = None + email: str | None = None + + +class _EventObject(_Native): + id: str | None = None + object: str + + +class _EventData(_Native): + object: _EventObject + + +class _Original(_Native): + id: str | None = None + object: str + created: Timestamp | None = None + livemode: bool | None = None + name: str | None = None + description: str | None = None + currency: str | None = None + status: str | None = None + type: str | None = None + amount: int | None = None + customer: str | _Reference | None = None + invoice: str | _Reference | None = None + charge: str | _Reference | None = None + payment_intent: str | _Reference | None = None + source: str | _Reference | None = None + destination: str | _Reference | None = None + captured: bool | None = None + paid: bool | None = None + refunded: bool | None = None + delinquent: bool | None = None + cancel_at_period_end: bool | None = None + email: str | None = None + phone: str | None = None + invoice_prefix: str | None = None + number: str | None = None + amount_due: int | None = None + amount_paid: int | None = None + amount_remaining: int | None = None + due_date: Timestamp | None = None + arrival_date: Timestamp | None = None + current_period_start: Timestamp | None = None + current_period_end: Timestamp | None = None + canceled_at: Timestamp | None = None + fee: int | None = None + net: int | None = None + reporting_category: str | None = None + method: str | None = None + reason: str | None = None + api_version: str | None = None + pending_webhooks: int | None = None + data: _EventData | None = None + billing_details: _Billing | None = None + available: list[_Money] | None = None + pending: list[_Money] | None = None + instant_available: list[_Money] | None = None + connect_reserved: list[_Money] | None = None + + +def _id(value: str | _Reference | None) -> str | None: + return value.id if isinstance(value, _Reference) else value + + +def _time(value: int | None) -> datetime | None: + return datetime.fromtimestamp(value, timezone.utc) if value is not None else None + + +def map_stripe(record: SourceRecord) -> tuple[BaseEntity, ...]: + """Project one retained root; reject identity/schema drift without logging originals.""" + if ( + record.payload_schema_version != 1 + or record.identity.container_id is not None + or record.parent is not None + or record.deleted_at is not None + or record.content_access != "available" + ): + raise ValueError("Stripe projection requires an available schema-1 root") + try: + native = _Original.model_validate(record.payload) + if native.object != record.identity.record_type: + raise ValueError("Stripe original kind differs from canonical identity") + if native.object == "balance": + if record.identity.native_id != "balance" or native.id is not None: + raise ValueError("Stripe balance identity differs from canonical identity") + return (_balance(record, native),) + if native.id != record.identity.native_id: + raise ValueError("Stripe original ID differs from canonical identity") + return (_entity(record, native),) + except (ValidationError, OverflowError, OSError): + raise ValueError("Stripe projection fields are malformed") from None + + +def _balance(record: SourceRecord, n: _Original) -> StripeBalanceEntity: + if n.available is None or n.pending is None or n.livemode is None: + raise ValueError("Stripe balance requires captured amounts and mode") + return StripeBalanceEntity( + entity_id="balance", + name="Account Balance", + breadcrumbs=[], + balance_id="balance", + balance_name="Account Balance", + snapshot_time=record.observed_at, + available=[item.model_dump() for item in n.available], + pending=[item.model_dump() for item in n.pending], + instant_available=( + [item.model_dump() for item in n.instant_available] + if n.instant_available is not None + else None + ), + connect_reserved=( + [item.model_dump() for item in n.connect_reserved] + if n.connect_reserved is not None + else None + ), + livemode=n.livemode, + ) + + +def _entity(record: SourceRecord, n: _Original) -> BaseEntity: # noqa: C901 + created = _time(n.created) + if created is None: + raise ValueError("Stripe original lacks a captured creation timestamp") + kind = n.object + name = n.name if kind == "customer" else n.number if kind == "invoice" else None + name = name or n.description or f"{kind.replace('_', ' ').title()} {n.id}" + common = { + "entity_id": n.id, + "name": name, + "breadcrumbs": [], + "created_at": created, + "updated_at": record.source_updated_at, + "created_time": created, + } + updated = {"updated_time": record.source_updated_at} + if kind == "balance_transaction": + return StripeBalanceTransactionEntity( + **common, + transaction_id=n.id, + transaction_name=name, + amount=n.amount, + currency=n.currency, + description=n.description, + fee=n.fee, + net=n.net, + reporting_category=n.reporting_category, + source=_id(n.source), + status=n.status, + type=n.type, + ) + if kind == "charge": + return StripeChargeEntity( + **common, + **updated, + charge_id=n.id, + charge_name=name, + amount=n.amount, + currency=n.currency, + description=n.description, + captured=n.captured, + paid=n.paid, + refunded=n.refunded, + customer_id=_id(n.customer), + invoice_id=_id(n.invoice), + ) + if kind == "customer": + return StripeCustomerEntity( + **common, + **updated, + customer_id=n.id, + customer_name=name, + email=n.email, + phone=n.phone, + description=n.description, + currency=n.currency, + delinquent=n.delinquent, + invoice_prefix=n.invoice_prefix, + ) + if kind == "event": + if n.data is None or n.livemode is None or n.type is None: + raise ValueError("Stripe event lacks captured type, mode or object identity") + return StripeEventEntity( + **common, + event_id=n.id, + event_name=n.type, + event_type=n.type, + api_version=n.api_version, + data=n.data.model_dump(exclude_none=True), + livemode=n.livemode, + pending_webhooks=n.pending_webhooks, + ) + if kind == "invoice": + return StripeInvoiceEntity( + **common, + **updated, + invoice_id=n.id, + invoice_name=name, + customer_id=_id(n.customer), + number=n.number, + status=n.status, + amount_due=n.amount_due, + amount_paid=n.amount_paid, + amount_remaining=n.amount_remaining, + due_date=_time(n.due_date), + paid=n.paid, + currency=n.currency, + ) + if kind == "payment_intent": + return StripePaymentIntentEntity( + **common, + **updated, + payment_intent_id=n.id, + payment_intent_name=name, + amount=n.amount, + currency=n.currency, + status=n.status, + description=n.description, + customer_id=_id(n.customer), + ) + if kind == "payment_method": + return StripePaymentMethodEntity( + **common, + payment_method_id=n.id, + payment_method_name=name, + type=n.type, + customer_id=_id(n.customer), + billing_details=n.billing_details.model_dump(exclude_none=True) + if n.billing_details + else {}, + ) + if kind == "payout": + return StripePayoutEntity( + **common, + **updated, + payout_id=n.id, + payout_name=name, + amount=n.amount, + currency=n.currency, + arrival_date=_time(n.arrival_date), + description=n.description, + destination=_id(n.destination), + method=n.method, + status=n.status, + ) + if kind == "refund": + return StripeRefundEntity( + **common, + refund_id=n.id, + refund_name=name, + amount=n.amount, + currency=n.currency, + status=n.status, + reason=n.reason, + charge_id=_id(n.charge), + payment_intent_id=_id(n.payment_intent), + ) + if kind == "subscription": + return StripeSubscriptionEntity( + **common, + **updated, + subscription_id=n.id, + subscription_name=name, + customer_id=_id(n.customer), + status=n.status, + current_period_start=_time(n.current_period_start), + current_period_end=_time(n.current_period_end), + cancel_at_period_end=n.cancel_at_period_end, + canceled_at=_time(n.canceled_at), + ) + raise ValueError("Unsupported Stripe original kind") diff --git a/backend/airweave/domains/entities/canonical/tests/conftest.py b/backend/airweave/domains/entities/canonical/tests/conftest.py new file mode 100644 index 0000000000..b77c43f40d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/conftest.py @@ -0,0 +1,310 @@ +"""Isolated real PostgreSQL fixtures shared by canonical store/query tests.""" + +import importlib.util +import os +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.models import Organization, Sync, SyncJob +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def migrate(connection, filename): + """Run the actual migration with this connection's isolated search_path.""" + path = Path(__file__).resolve().parents[5] / "alembic" / "versions" / filename + spec = importlib.util.spec_from_file_location("canonical_test_migration", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with Operations.context(MigrationContext.configure(connection)): + module.upgrade() + + +def seed_legacy(connection): + """Insert an existing metadata row using the pre-migration schema.""" + from sqlalchemy import MetaData, Table + + metadata = MetaData() + tables = { + name: Table(name, metadata, autoload_with=connection) + for name in ("organization", "sync", "sync_job", "entity") + } + org_id, sync_id, job_id, entity_id = [uuid4() for _ in range(4)] + now = datetime.now(timezone.utc).replace(tzinfo=None) + times = {"created_at": now, "modified_at": now} + connection.execute( + tables["organization"].insert().values(id=org_id, name="Legacy synthetic", **times) + ) + connection.execute( + tables["sync"] + .insert() + .values( + id=sync_id, + organization_id=org_id, + name="Legacy sync", + status="ACTIVE", + sync_type="full", + **times, + ) + ) + connection.execute( + tables["sync_job"] + .insert() + .values( + id=job_id, + organization_id=org_id, + sync_id=sync_id, + status="completed", + entities_inserted=1, + entities_updated=0, + entities_deleted=0, + entities_kept=0, + entities_skipped=0, + scheduled=False, + **times, + ) + ) + connection.execute( + tables["entity"] + .insert() + .values( + id=entity_id, + organization_id=org_id, + sync_id=sync_id, + sync_job_id=job_id, + entity_id="legacy-native-key", + entity_definition_short_name="event", + hash="old-hash", + **times, + ) + ) + + +def seed_flat_visibility(connection): + """Canonical flat states from schema0005, including already withdrawn content.""" + import json + + from sqlalchemy import MetaData, Table + + table = Table("entity", MetaData(), autoload_with=connection) + existing = connection.execute( + text("SELECT organization_id,sync_id,sync_job_id FROM entity LIMIT 1") + ).one() + now = datetime.now(timezone.utc) + for native, parent, reason in ( + ("active-root", None, None), + ("withdrawn-root", None, "access_revoked"), + ("active-child", "active-root", None), + ("withdrawn-child", "active-root", "scope_removed"), + ("hidden-child", "withdrawn-root", None), + ("orphan", "missing", None), + ): + connection.execute( + table.insert().values( + id=uuid4(), + organization_id=existing.organization_id, + sync_id=existing.sync_id, + sync_job_id=existing.sync_job_id, + entity_id=json.dumps([None, native], separators=(",", ":")), + entity_definition_short_name="block", + native_id=native, + parent_record_type="block" if parent else None, + parent_native_id=parent, + source_payload={"native": native}, + record_revision=1, + hash="unchanged", + capture_hash="unchanged", + completeness="complete", + observed_at=now, + deleted_at=now if reason else None, + removal_reason=reason, + created_at=now.replace(tzinfo=None), + modified_at=now.replace(tzinfo=None), + ) + ) + + +def seed_flat_scan(connection, mode="scan_upgrade"): + """A schema1 active cycle with an acknowledged provider continuation before0007.""" + from sqlalchemy import MetaData, Table + + metadata = MetaData() + cursor = Table("sync_cursor", metadata, autoload_with=connection) + scans = Table("capture_scan", metadata, autoload_with=connection) + parent = connection.execute( + text("SELECT organization_id,sync_id FROM entity WHERE native_id='active-root'") + ).one() + cycle_id, sweep_id = uuid4(), uuid4() + now = datetime.now(timezone.utc) + connection.execute( + cursor.insert().values( + id=uuid4(), + organization_id=parent.organization_id, + sync_id=parent.sync_id, + created_at=now.replace(tzinfo=None), + modified_at=now.replace(tzinfo=None), + last_updated=now, + cursor_data={ + "canonical_cycle": { + "schema_version": 1, + "version": {"cycle_id": str(cycle_id), "revision": 7}, + "configuration": { + "fingerprint": "a" * 64, + "root_record_type": "block", + "child_record_types": ["leaf"], + }, + "phase": "active", + "root_writer_attempt_id": str(uuid4()), + } + }, + ) + ) + connection.execute( + scans.insert().values( + id=uuid4(), + organization_id=parent.organization_id, + sync_id=parent.sync_id, + created_at=now.replace(tzinfo=None), + modified_at=now.replace(tzinfo=None), + scope_key='["leaf","active-root"]', + record_type="leaf", + container_id="active-root", + cycle_id=cycle_id, + sweep_id=sweep_id, + revision=11, + phase="collecting", + fingerprint="a" * 64, + continuation={"cursor": "synthetic-acknowledged-page"}, + started_at=now, + ) + ) + + if mode != "scan_upgrade": + stored = connection.execute(cursor.select()).mappings().one() + changed = dict(stored["cursor_data"]) + legacy = changed["canonical_cycle"] + if mode == "scan_upgrade_children_object": + legacy["configuration"]["child_record_types"] = {"leaf": True} + elif mode == "scan_upgrade_foreign_version": + legacy["schema_version"] = 77 + elif mode == "scan_upgrade_completed": + legacy["phase"] = "complete" + elif mode == "scan_upgrade_too_many_children": + legacy["configuration"]["child_record_types"] = [ + "leaf", + *[f"kind{i}" for i in range(20)], + ] + elif mode == "scan_upgrade_default_children": + legacy["configuration"].pop("child_record_types") + connection.execute( + scans.insert().values( + id=uuid4(), + organization_id=parent.organization_id, + sync_id=parent.sync_id, + created_at=now.replace(tzinfo=None), + modified_at=now.replace(tzinfo=None), + scope_key='["block",null]', + record_type="block", + container_id=None, + cycle_id=cycle_id, + sweep_id=uuid4(), + revision=9, + phase="complete", + fingerprint="a" * 64, + continuation={}, + started_at=now, + completed_at=now, + ) + ) + connection.execute(cursor.update().values(cursor_data=changed)) + + +@pytest.fixture +async def database(request): + """Every test gets its own schema; no global application DSN is consulted.""" + url = os.environ.get("CANONICAL_TEST_DATABASE_URL") + if not url: + pytest.skip("Set CANONICAL_TEST_DATABASE_URL to a disposable PostgreSQL database") + schema = "canonical_test_" + uuid4().hex + admin = create_async_engine(url) + async with admin.begin() as connection: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": schema}}) + try: + async with engine.begin() as connection: + await connection.run_sync(migrate, "0000_baseline.py") + if getattr(request, "param", None) in ( + "legacy", + "forest_upgrade", + "scan_upgrade", + "scan_upgrade_children_object", + "scan_upgrade_foreign_version", + "scan_upgrade_completed", + "scan_upgrade_default_children", + "scan_upgrade_too_many_children", + ): + await connection.run_sync(seed_legacy) + await connection.run_sync(migrate, "0001_canonical_records.py") + await connection.run_sync(migrate, "0002_projection_publication.py") + await connection.run_sync(migrate, "0003_mail_thread_index.py") + await connection.run_sync(migrate, "0004_projection_generation.py") + await connection.run_sync(migrate, "0005_capture_scan.py") + if getattr(request, "param", None) in ( + "forest_upgrade", + "scan_upgrade", + "scan_upgrade_children_object", + "scan_upgrade_foreign_version", + "scan_upgrade_completed", + "scan_upgrade_default_children", + "scan_upgrade_too_many_children", + ): + await connection.run_sync(seed_flat_visibility) + await connection.run_sync(migrate, "0006_record_visibility.py") + if getattr(request, "param", None) in ( + "scan_upgrade", + "scan_upgrade_children_object", + "scan_upgrade_foreign_version", + "scan_upgrade_completed", + "scan_upgrade_default_children", + "scan_upgrade_too_many_children", + ): + await connection.run_sync(seed_flat_scan, request.param) + await connection.run_sync(migrate, "0007_scan_scope_owner.py") + await connection.run_sync(migrate, "0008_scope_execution.py") + await connection.run_sync(migrate, "0009_extraction_coverage.py") + await connection.run_sync(migrate, "0010_owned_provisioning.py") + await connection.run_sync(migrate, "0011_retained_projection_text.py") + await connection.run_sync(migrate, "0012_scan_parent_verification.py") + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + async with admin.begin() as connection: + await connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + await admin.dispose() + + +@pytest.fixture +async def source(database): + organization_id, sync_id, job_id = uuid4(), uuid4(), uuid4() + async with database() as db: + db.add(Organization(id=organization_id, name="Synthetic canonical tests")) + await db.flush() + db.add(Sync(id=sync_id, organization_id=organization_id, name="Synthetic Gmail")) + await db.flush() + db.add( + SyncJob(id=job_id, organization_id=organization_id, sync_id=sync_id, status="running") + ) + await db.commit() + service = CanonicalCaptureService(CanonicalRecordStore()) + async with database() as db: + fence = await service.activate_writer( + db, organization_id, sync_id, job_id, attempt_id=uuid4(), attempt_number=1 + ) + return service, fence diff --git a/backend/airweave/domains/entities/canonical/tests/helpers.py b/backend/airweave/domains/entities/canonical/tests/helpers.py new file mode 100644 index 0000000000..d27531b583 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/helpers.py @@ -0,0 +1,102 @@ +"""Synthetic capture inputs and transaction invocation for database tests.""" + +from datetime import datetime, timezone + +from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord, RecordIdentity + + +def observation(native_id="one", container_id=None, **changes): + values = { + "identity": RecordIdentity( + record_type="event", native_id=native_id, container_id=container_id + ), + "payload": {"id": native_id, "summary": "Synthetic event"}, + "observed_at": datetime.now(timezone.utc), + "content_hash": "same-body", + } + values.update(changes) + return CaptureRecord(**values) + + +async def capture(database, service, fence, *records): + async with database() as db: + return await service.capture(db, CaptureBatch(fence=fence, records=records)) + + +async def publish_prepared(store, db, work, generation, count, collection_id=None): + """Synthetic exact manifests exercise the same mandatory pre-feed transaction.""" + from airweave.domains.entities.canonical.projection_models import ( + ProjectionDocument, + ProjectionLocator, + scope_projection_document_id, + ) + + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ).encode() + collection_id = collection_id or work.binding.collection_id + prepared = await store.prepare( + db, + work, + generation, + collection_id, + tuple( + ProjectionDocument( + schema_name="base_entity", + document_id=scope_projection_document_id( + work.record.sync_id, collection_id, f"Entity_{locator}__chunk_{index}" + ), + ) + for index in range(count) + ), + ) + return prepared and await store.publish(db, work, generation, count) + + +async def bind_projection(database, fence, source_name="gmail", collection_id=None): + """Create the real authenticated source/collection required for synthetic projection.""" + from uuid import uuid4 + + from airweave.domains.entities.canonical.projection_models import ProjectionBinding + from airweave.models.collection import Collection + from airweave.models.source_connection import SourceConnection + from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + collection_id = collection_id or uuid4() + readable_id = str(collection_id) + source_id = uuid4() + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="test", embedding_dimensions=3, sparse_embedder="test" + ) + db.add(deployment) + await db.flush() + db.add( + Collection( + id=collection_id, + name="Synthetic projection", + readable_id=readable_id, + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + ) + await db.flush() + db.add( + SourceConnection( + id=source_id, + name="Synthetic projection", + short_name=source_name, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + readable_collection_id=readable_id, + is_authenticated=True, + ) + ) + await db.commit() + return ProjectionBinding( + source_connection_id=source_id, source_name=source_name, collection_id=collection_id + ) diff --git a/backend/airweave/domains/entities/canonical/tests/test_attio_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_attio_recovery.py new file mode 100644 index 0000000000..6c4c843767 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_attio_recovery.py @@ -0,0 +1,138 @@ +"""Actual Attio HTTP adapter and PostgreSQL page recovery with synthetic provider data.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CompleteCycle +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.platform.configs.config import AttioConfig +from airweave.platform.sources.attio import AttioSource + +WORKSPACE, OBJECT, RECORD = [str(UUID(int=i)) for i in range(1, 4)] +OBJECT_DATA = {"id": {"workspace_id": WORKSPACE, "object_id": OBJECT}, "api_slug": "people"} +RECORD_DATA = { + "id": {"workspace_id": WORKSPACE, "object_id": OBJECT, "record_id": RECORD}, + "values": {}, +} +NOTES = [ + { + "id": {"workspace_id": WORKSPACE, "note_id": str(UUID(int=i + 100))}, + "parent_object": "people", + "parent_record_id": RECORD, + "content_markdown": "Original note", + "unknown": {"retained": True}, + } + for i in range(51) +] + + +async def connector(calls, *, fail=False): + def result(path, data, status=200): + return httpx.Response(status, json=data, request=httpx.Request("GET", path)) + + async def get(path, **kwargs): + calls.append((path, kwargs.get("params"))) + if path.endswith("/self"): + return result(path, {"active": True, "workspace_id": WORKSPACE}) + if path.endswith("/objects"): + return result(path, {"data": [OBJECT_DATA]}) + if path.endswith("/lists"): + return result(path, {"data": []}) + if path.endswith("/objects/" + OBJECT): + return result(path, {"data": OBJECT_DATA}) + assert path.endswith("/notes") + offset = kwargs["params"]["offset"] + if fail and offset == 50: + return result(path, {"code": "merge_in_progress"}, 404) + return result(path, {"data": NOTES[offset : offset + 50]}) + + async def post(path, **kwargs): + calls.append((path, kwargs["json"])) + assert path.endswith("/records/query") + return result(path, {"data": [RECORD_DATA]}) + + client = AsyncMock() + client.get.side_effect = get + client.post.side_effect = post + return await AttioSource.create( + auth=ManagedAuthProvider( + api_key="fixture", + connected_account_id="fixture", + allowed_hosts=frozenset({"api.attio.com"}), + ), + logger=MagicMock(), + http_client=client, + config=AttioConfig(workspace_id=WORKSPACE), + ) + + +def driver(service, database, fence, connector): + return CanonicalScanDriver( + service, database, fence, connector, AsyncMock(), AsyncMock(), MagicMock() + ) + + +async def test_note_page_failure_recovers_exact_offset_without_replaying_fifty_notes( + database, source +): + service, fence = source + calls = [] + first = await connector(calls, fail=True) + with pytest.raises(SourceError): + await driver(service, database, fence, first).run() + async with database() as db: + notes = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "note") + ) + ).all() + ) + assert len(notes) == 50 + assert all(row.deleted_at is None and row.record_revision == 1 for row in notes) + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "note")) + assert scan.phase == "collecting" and scan.continuation["offset"] == 50 + before = await service.read_cycle(db, fence) + assert before.phase == "active" + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + retry_calls = [] + resumed = await connector(retry_calls) + after = await driver(service, database, newer, resumed).run() + async with database() as db: + after = await service.complete_cycle(db, CompleteCycle(fence=newer, expected=after.version)) + assert after.phase == "complete" and after.version.cycle_id == before.version.cycle_id + assert after.last_full_capture.discovery == "incomplete" + assert [params["offset"] for path, params in retry_calls if path.endswith("/notes")] == [50] + # Discovery-only ancestors are refreshed on resume; the committed note page is not. + assert any(path.endswith("/records/query") for path, _ in retry_calls) + assert all( + after.configuration.policy(kind) == "discovery_only" + for kind in resumed.canonical_record_types + ) + async with database() as db: + notes = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "note") + ) + ).all() + ) + assert len(notes) == 51 + assert all(row.deleted_at is None and row.record_revision == 1 for row in notes) + assert all(row.source_payload["unknown"] == {"retained": True} for row in notes) diff --git a/backend/airweave/domains/entities/canonical/tests/test_calendar_exact.py b/backend/airweave/domains/entities/canonical/tests/test_calendar_exact.py new file mode 100644 index 0000000000..d527d6ef3e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_calendar_exact.py @@ -0,0 +1,221 @@ +"""Real SQL checks for native identity, version conflicts and withdrawn scope.""" + +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.calendar_exact import CalendarReadIncomplete, read_event +from airweave.domains.entities.canonical.query import RecordNotFound +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.platform.sources.records.google_calendar import record + + +async def seed( + database, + source, + raw_status="confirmed", + expanded_status="cancelled", + raw_updated="2026-09-01T00:00:00Z", + expanded_updated="2026-09-02T00:00:00Z", +): + capture, fence = source + parent = record("calendar", {"id": "cal"}) + + def item(kind, status, updated): + payload = { + "id": "exact", + "status": status, + "updated": updated, + "summary": "private body", + "start": {"date": "2026-09-01"}, + "end": {"date": "2026-09-02"}, + } + return record(kind, payload, "cal").model_copy(update={"parent": parent.identity}) + + observations = ( + parent, + item("event", raw_status, raw_updated), + item("event_occurrence", expanded_status, expanded_updated), + ) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=observations)) + return parent + + +async def test_newer_provider_deletion_redacts_body_and_wrong_scope_is_absent(database, source): + await seed(database, source) + _, fence = source + async with database() as db: + result = await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + assert result.deleted_at and result.payload == {} and result.blobs == () + assert result.identity.record_type == "event_occurrence" + with pytest.raises(RecordNotFound): + await read_event(db, fence.organization_id, fence.sync_id, "other", "exact") + with pytest.raises(RecordNotFound): + await read_event(db, uuid4(), fence.sync_id, "cal", "exact") + + +async def test_reinstatement_uses_provider_version_not_capture_order(database, source): + await seed(database, source, "cancelled", "confirmed") + _, fence = source + async with database() as db: + result = await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + assert result.deleted_at is None and result.payload["status"] == "confirmed" + + +async def test_equal_versions_conflicting_status_fail_closed(database, source): + await seed(database, source, expanded_updated="2026-09-01T00:00:00Z") + _, fence = source + async with database() as db: + with pytest.raises(CalendarReadIncomplete): + await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + + +async def test_parent_withdrawal_blocks_both_candidates(database, source): + parent = await seed(database, source) + capture, fence = source + withdrawn = parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(withdrawn,))) + async with database() as db: + with pytest.raises(RecordNotFound): + await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + + +async def test_horizon_eviction_does_not_delete_raw_event(database, source): + await seed(database, source, expanded_status="confirmed") + capture, fence = source + eviction = record("event_occurrence", {"id": "exact"}, "cal").model_copy( + update={"kind": "delete", "removal_reason": "scope_removed"} + ) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(eviction,))) + async with database() as db: + result = await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + assert result.deleted_at is None and result.identity.record_type == "event" + + +async def test_missing_native_versions_fail_closed(database, source): + await seed(database, source, raw_updated=None, expanded_updated=None) + _, fence = source + async with database() as db: + with pytest.raises(CalendarReadIncomplete): + await read_event(db, fence.organization_id, fence.sync_id, "cal", "exact") + + +async def test_cancelled_recurring_tombstone_keeps_only_exclusion_identity(database, source): + capture, fence = source + parent = record("calendar", {"id": "cal"}) + exclusion = record( + "event_occurrence", + { + "id": "exception", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-09-01"}, + "summary": "must not leak", + "attendees": [{"email": "private@example.com"}], + }, + "cal", + ).model_copy(update={"parent": parent.identity}) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(parent, exclusion))) + async with database() as db: + result = await read_event(db, fence.organization_id, fence.sync_id, "cal", "exception") + assert result.deleted_at is not None + assert result.payload == { + "id": "exception", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-09-01"}, + } + + +async def test_exact_http_is_source_authenticated_and_tenant_scoped(database, source): + from types import SimpleNamespace + + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + from sqlalchemy import update + + from airweave.api import deps + from airweave.api.v1.endpoints.calendar_records import router + from airweave.api.v1.endpoints.records import record_error_response + from airweave.db.session import get_db + from airweave.domains.entities.canonical.store import CanonicalStoreError + from airweave.models.source_connection import SourceConnection + + await seed(database, source) + _, fence = source + async with database() as db: + db.add( + SourceConnection( + organization_id=fence.organization_id, + sync_id=fence.sync_id, + name="Synthetic", + short_name="google_calendar", + is_authenticated=True, + ) + ) + await db.commit() + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + owner = fence.organization_id + + async def context(): + return SimpleNamespace(organization=SimpleNamespace(id=owner)) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[get_db] = session + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + path = f"/sync/{fence.sync_id}/calendar/events/exact" + result = await client.get(path, params={"calendar_id": "cal"}) + assert result.status_code == 200 and result.json()["payload"] == {} + owner = uuid4() + assert (await client.get(path, params={"calendar_id": "cal"})).status_code == 404 + owner = fence.organization_id + async with database() as db: + await db.execute( + update(SourceConnection) + .where(SourceConnection.sync_id == fence.sync_id) + .values(is_authenticated=False) + ) + await db.commit() + assert (await client.get(path, params={"calendar_id": "cal"})).status_code == 404 + + +async def test_same_cancelled_exception_raw_state_beats_duplicate_tombstone(database, source): + from airweave.domains.entities.canonical.calendar_exact import choose + + capture, fence = source + parent = record("calendar", {"id": "cal"}) + payload = { + "id": "exception", + "status": "cancelled", + "etag": "same", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-09-01"}, + } + observations = tuple( + record(kind, payload, "cal").model_copy(update={"parent": parent.identity}) + for kind in ("event", "event_occurrence") + ) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(parent, *observations))) + async with database() as db: + result = await read_event(db, fence.organization_id, fence.sync_id, "cal", "exception") + assert result.deleted_at is None and result.identity.record_type == "event" + assert result.payload["originalStartTime"] == {"date": "2026-09-01"} + with pytest.raises(RecordNotFound): + choose( + ( + result.model_copy( + update={"content_access": "unavailable", "removal_reason": None} + ), + ) + ) diff --git a/backend/airweave/domains/entities/canonical/tests/test_calendar_exclusions.py b/backend/airweave/domains/entities/canonical/tests/test_calendar_exclusions.py new file mode 100644 index 0000000000..14aa947c73 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_calendar_exclusions.py @@ -0,0 +1,195 @@ +"""Cancelled instances remain recurrence facts, without becoming searchable meetings.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_models import RecordFilters, RecordListQuery +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.helpers import bind_projection +from airweave.models import Entity, ProjectionGeneration +from airweave.platform.sources.records.google_calendar import record + + +async def test_cancelled_exception_read_project_reinstate_and_revoke(database, source): + capture, fence = source + binding = await bind_projection(database, fence, "google_calendar") + parent = record("calendar", {"id": "cal"}) + native = { + "id": "instance", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-10-01"}, + } + excluded = record("event", native, "cal").model_copy(update={"parent": parent.identity}) + async with database() as db: + result = await capture.capture(db, CaptureBatch(fence=fence, records=(parent, excluded))) + row = result.changes[-1].record + queries = CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-key") + projections = CanonicalProjectionStore() + async with database() as db: + current = await queries.read(db, fence.organization_id, fence.sync_id, row.id) + assert current.deleted_at is None and current.payload == native + page = await queries.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=RecordFilters(record_type="event")), + ) + assert len(page.records) == 1 and page.records[0].payload["status"] == "cancelled" + work = next( + w + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + if w.record.id == row.id + ) + async with map_record(current, "google_calendar", AsyncMock()) as mapped: + mapped = mapped.entities + assert mapped == () + processor = MagicMock() + processor.build_text = AsyncMock() + destination = MagicMock() + destination.collection_id = binding.collection_id + destination.feed_prepared = AsyncMock() + projector = CanonicalProjector(projections, database, processor, AsyncMock()) + assert await projector.project_one(work, "google_calendar", destination, MagicMock()) + processor.build_text.assert_not_awaited() + destination.feed_prepared.assert_not_awaited() + async with database() as db: + assert all( + w.record.id != row.id + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + ) + manifest = await db.scalar( + select(ProjectionGeneration).where(ProjectionGeneration.record_id == row.id) + ) + assert manifest.documents == [] + assert (await db.get(Entity, row.id)).indexed_chunk_count == 0 + restored = record( + "event", + { + **native, + "status": "confirmed", + "summary": "Restored", + "start": {"date": "2026-10-01"}, + "end": {"date": "2026-10-02"}, + }, + "cal", + ).model_copy(update={"parent": parent.identity}) + async with database() as db: + result = await capture.capture(db, CaptureBatch(fence=fence, records=(restored,))) + assert result.changes[0].record.id == row.id and result.changes[0].record.revision == 2 + assert any( + w.record.id == row.id + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + ) + current = await queries.read(db, fence.organization_id, fence.sync_id, row.id) + async with map_record(current, "google_calendar", AsyncMock()) as mapped: + mapped = mapped.entities + assert len(mapped) == 1 and mapped[0].status == "confirmed" + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(excluded,))) + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + parent.model_copy( + update={"kind": "delete", "removal_reason": "access_revoked"} + ), + ), + ), + ) + hidden = await queries.read(db, fence.organization_id, fence.sync_id, row.id) + assert hidden.content_access == "unavailable" and hidden.payload == {} and not hidden.blobs + page = await queries.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=RecordFilters(record_type="event")), + ) + assert not page.records + assert all( + w.record.id != row.id + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + ) + + +async def test_live_event_cannot_publish_empty_or_exclusion_nonempty(database, source): + capture, fence = source + binding = await bind_projection(database, fence, "google_calendar") + fact = record( + "event", + { + "id": "cancelled", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-10-01"}, + }, + "cal", + ) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(fact,))) + store = CanonicalProjectionStore() + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + with pytest.raises(ValueError, match="exclusions"): + await store.publish(db, work, uuid4(), 1) + active = work.model_copy( + update={ + "record": work.record.model_copy( + update={"payload": {"id": "cancelled", "status": "confirmed"}} + ) + } + ) + generation = uuid4() + assert await store.prepare(db, active, generation, binding.collection_id, ()) + with pytest.raises(ValueError, match="indexed content"): + await store.publish(db, active, generation, 0) + + +async def test_operational_occurrences_publish_zero_documents_without_retry(database, source): + capture, fence = source + binding = await bind_projection(database, fence, "google_calendar") + parent = record("calendar", {"id": "cal"}) + occurrence = record( + "event_occurrence", + { + "id": "real-instance", + "status": "confirmed", + "start": {"date": "2026-10-01"}, + "end": {"date": "2026-10-02"}, + }, + "cal", + ).model_copy(update={"parent": parent.identity}) + async with database() as db: + result = await capture.capture(db, CaptureBatch(fence=fence, records=(parent, occurrence))) + row = result.changes[-1].record + projections = CanonicalProjectionStore() + async with database() as db: + work = next( + w + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + if w.record.id == row.id + ) + processor = MagicMock() + processor.build_text = AsyncMock() + destination = MagicMock() + destination.collection_id = binding.collection_id + destination.feed_prepared = AsyncMock() + projector = CanonicalProjector(projections, database, processor, AsyncMock()) + assert await projector.project_one(work, "google_calendar", destination, MagicMock()) + processor.build_text.assert_not_awaited() + destination.feed_prepared.assert_not_awaited() + async with database() as db: + assert all( + w.record.id != row.id + for w in await projections.pending(db, fence.organization_id, fence.sync_id) + ) + assert (await db.get(Entity, row.id)).indexed_chunk_count == 0 diff --git a/backend/airweave/domains/entities/canonical/tests/test_calendar_lifecycle_probe.py b/backend/airweave/domains/entities/canonical/tests/test_calendar_lifecycle_probe.py new file mode 100644 index 0000000000..b2deb4c628 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_calendar_lifecycle_probe.py @@ -0,0 +1,94 @@ +"""The real live harness must preserve scoped capture through fresh child processes.""" + +import json + +from sqlalchemy import text + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + +SCRIPT = r""" +import asyncio, json, os, sys +from contextlib import asynccontextmanager +from unittest.mock import MagicMock +from urllib.parse import quote +sys.path.insert(0, 'tests/live') +import conftest +import httpx +import provider_lifecycle as lifecycle +from airweave.domains.entities.canonical.tests.test_calendar_recovery import NativeHTTP, event +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.sources.google_calendar import GoogleCalendarSource + +@asynccontextmanager +async def synthetic_source(name, account, expected_email, key, fence, **options): + assert name == 'google_calendar' + path = '/calendars/' + quote(expected_email, safe='') + '/events' + first = os.environ['TEST_STAGE'] == 'first' + native = NativeHTTP([ + ('/users/me/calendarList', {'items': [{'id': expected_email, 'timeZone': 'UTC'}]}), + (path, {'items': [event('a')] if first else [], + 'nextSyncToken': 'full' if first else 'delta'}), + (path, {'items': [event('a')]}), + ], principal=expected_email) + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle), + event_hooks={'request': [options['request_hook']]}) as client: + connector = await GoogleCalendarSource.create(auth=StaticTokenProvider('synthetic'), + logger=MagicMock(), http_client=client, config=options['calendar_config'].model_copy( + update={'expected_primary_calendar_id': expected_email})) + yield connector, 'provider_primary_calendar_id' + assert not native.replies + +lifecycle.rest_source = synthetic_source +lifecycle.harness.test_database_url = lambda: os.environ['CANONICAL_TEST_DATABASE_URL'] +os.environ['COMPOSIO_API_KEY'] = 'synthetic-test-only' +os.environ['LIVE_CALENDAR_ACCOUNT_ID'] = 'synthetic-test-only' +os.environ['LIVE_EXPECTED_EMAIL'] = 'synthetic@example.test' +raise SystemExit(asyncio.run(lifecycle.child(json.loads(os.environ['TEST_MANIFEST'])))) +""" + + +async def test_calendar_lifecycle_initial_then_published_scope_delta(database, source, tmp_path): + fence = source[1] + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + manifest = { + "provider": "google_calendar", + "schema": schema, + "root": str(tmp_path), + "organization_id": str(fence.organization_id), + "sync_id": str(fence.sync_id), + "record_limit": 10, + "request_limit": 20, + "timeout": 30, + "file_byte_limit": 1024, + "blob_byte_limit": 1024, + "calendar_config": { + "calendar_ids": ["synthetic@example.test"], + "occurrence_window": { + "start": "2026-03-01T00:00:00Z", + "end": "2026-04-01T00:00:00Z", + }, + }, + } + results = [] + for stage in ("first", "second"): + current = dict(manifest) + if stage == "first": + current.update(job_id=str(fence.job_id), attempt_number=2) + code, output, error = await subprocess_script( + SCRIPT, {"TEST_MANIFEST": json.dumps(current), "TEST_STAGE": stage} + ) + if code != 0: + raise AssertionError(output + error) + result = json.loads(output.splitlines()[-1]) + assert not result["failed"] and result["job_status"] == "completed" + assert result["stored_records"] == 3 and result["partial_records"] == 0 + assert result["provider_requests"] == 4 + results.append(result) + first, second = results + assert first["records_observed"] == 3 and second["records_observed"] == 2 + assert not first["loaded_durable_checkpoint"] and second["loaded_durable_checkpoint"] + assert first["sync_token_requests"] == 0 and second["sync_token_requests"] == 1 + assert first["full_scope_completed"] and not second["full_scope_completed"] + assert first["observed_change_sequence"] == second["observed_change_sequence"] + assert first["payload_revision_digest"] == second["payload_revision_digest"] diff --git a/backend/airweave/domains/entities/canonical/tests/test_calendar_range.py b/backend/airweave/domains/entities/canonical/tests/test_calendar_range.py new file mode 100644 index 0000000000..9c377b066b --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_calendar_range.py @@ -0,0 +1,313 @@ +"""Stored provider instances support bounded ranges without recurrence guessing.""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from airweave.api import deps +from airweave.api.v1.endpoints.calendar_records import router +from airweave.api.v1.endpoints.records import record_error_response +from airweave.db.session import get_db +from airweave.domains.entities.canonical.calendar_query import ( + CalendarChanged, + CalendarRange, + CalendarRangeNotCaptured, + CalendarRangeService, +) +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.store import CanonicalStoreError +from airweave.models import SyncCursor +from airweave.models.source_connection import SourceConnection +from airweave.platform.sources.records.google_calendar import record + + +def window(): + return CalendarRange( + start="2026-03-08T00:00:00-08:00", + end="2026-03-10T00:00:00-07:00", + timezone="America/Los_Angeles", + limit=1, + ) + + +async def seed(database, source): + capture, fence = source + parent = record("calendar", {"id": "cal", "timeZone": "America/Los_Angeles"}) + events = [ + {"id": "all-day", "start": {"date": "2026-03-08"}, "end": {"date": "2026-03-09"}}, + { + "id": "real-instance-id", + "recurringEventId": "master", + "originalStartTime": {"dateTime": "2026-03-01T09:00:00-08:00"}, + "start": {"dateTime": "2026-03-09T09:00:00-07:00"}, + "end": {"dateTime": "2026-03-09T10:00:00-07:00"}, + }, + {"id": "ends-at-boundary", "start": {"date": "2026-03-07"}, "end": {"date": "2026-03-08"}}, + ] + from airweave.domains.entities.canonical.tests.test_calendar_recovery import ( + NativeHTTP, + run, + setup, + ) + from airweave.platform.configs.config import GoogleCalendarConfig + + native = NativeHTTP( + [ + ("/users/me/calendarList", {"items": [parent.payload]}), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "seed"}), + ("/calendars/cal/events", {"items": events}), + ] + ) + config = GoogleCalendarConfig( + occurrence_window={"start": "2026-03-01T00:00:00Z", "end": "2026-04-01T00:00:00Z"} + ) + pipeline, ctx, runtime, client = await setup(database, source, native, config=config) + await run(pipeline, ctx, runtime) + await client.aclose() + async with database() as db: + result = await CalendarRangeService("secret").read( + db, fence.organization_id, fence.sync_id, "cal", window() + ) + coverage = result.coverage + return parent, coverage + + +async def test_paged_range_all_day_moved_instance_identity_and_changes(database, source): + parent, coverage = await seed(database, source) + capture, fence = source + service = CalendarRangeService("secret") + async with database() as db: + first = await service.read(db, fence.organization_id, fence.sync_id, "cal", window()) + assert first.records[0].identity.native_id == "all-day" + assert first.has_more and first.refresh_state == "last_completed" + next_query = window().model_copy(update={"cursor": first.next_cursor}) + second = await service.read(db, fence.organization_id, fence.sync_id, "cal", next_query) + assert second.records[0].identity.native_id == "real-instance-id" and not second.has_more + saved = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == fence.sync_id)) + assert saved.cursor_data["canonical_checkpoint"]["writer_attempt_id"] == str( + fence.attempt_id + ) + assert saved.cursor_data["canonical_checkpoint"]["observed_change_sequence"] == 4 + async with database() as db: + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + record( + "event_occurrence", + { + "id": "late", + "start": {"date": "2026-03-09"}, + "end": {"date": "2026-03-10"}, + }, + "cal", + ), + ), + ), + ) + async with database() as db: + with pytest.raises(CalendarChanged): + await service.read(db, fence.organization_id, fence.sync_id, "cal", next_query) + current = await service.read(db, fence.organization_id, fence.sync_id, "cal", window()) + assert current.coverage == coverage and current.refresh_state != "last_completed" + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + parent.model_copy( + update={"kind": "delete", "removal_reason": "access_revoked"} + ), + ), + ), + ) + async with database() as db: + with pytest.raises(CanonicalStoreError): + await service.read(db, fence.organization_id, fence.sync_id, "cal", window()) + + +async def test_http_missing_coverage_action_and_cross_org(database, source, monkeypatch): + capture, fence = source + async with database() as db: + await capture.capture( + db, + CaptureBatch( + fence=fence, records=(record("calendar", {"id": "cal", "timeZone": "UTC"}),) + ), + ) + async with database() as db: + db.add( + SourceConnection( + organization_id=fence.organization_id, + sync_id=fence.sync_id, + name="Synthetic Calendar", + short_name="google_calendar", + is_authenticated=True, + ) + ) + await db.commit() + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + owner = fence.organization_id + + async def context(): + return SimpleNamespace(organization=SimpleNamespace(id=owner)) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[get_db] = session + params = {"calendar_id": "cal", "start": "2026-03-01T00:00:00Z", "end": "2026-03-02T00:00:00Z"} + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + path = f"/sync/{fence.sync_id}/calendar/events" + missing = await client.get(path, params=params) + assert missing.status_code == 409 + assert missing.json()["error"]["action"]["operation"] == "sync_calendar_window" + owner = uuid4() + assert (await client.get(path, params=params)).status_code == 404 + owner = fence.organization_id + assert ( + await client.get(path, params={**params, "timezone": "not/a/zone"}) + ).status_code == 422 + await seed(database, source) + original_read = CalendarRangeService.read + + async def revoke_during_read(self, *args, **kwargs): + from sqlalchemy import update + + result = await original_read(self, *args, **kwargs) + async with database() as other: + await other.execute( + update(SourceConnection) + .where(SourceConnection.sync_id == fence.sync_id) + .values(is_authenticated=False) + ) + await other.commit() + return result + + monkeypatch.setattr(CalendarRangeService, "read", revoke_during_read) + assert (await client.get(path, params=params)).status_code == 404 + + +async def test_request_outside_successful_coverage_never_returns_empty_success(database, source): + await seed(database, source) + _, fence = source + async with database() as db: + with pytest.raises(CalendarRangeNotCaptured): + await CalendarRangeService("secret").read( + db, + fence.organization_id, + fence.sync_id, + "cal", + CalendarRange( + start="2001-01-01T00:00:00Z", end="2001-01-02T00:00:00Z", timezone="UTC" + ), + ) + + +async def test_empty_observed_range_is_distinct_from_missing_coverage(database, source): + await seed(database, source) + _, fence = source + async with database() as db: + page = await CalendarRangeService("secret").read( + db, + fence.organization_id, + fence.sync_id, + "cal", + CalendarRange(start="2026-03-20T00:00:00Z", end="2026-03-21T00:00:00Z", timezone="UTC"), + ) + assert page.records == () and not page.has_more + assert page.coverage and page.refresh_state == "last_completed" + + +async def test_budget_rejects_oversized_row_before_orm_materialization(database, source): + from sqlalchemy import update + + from airweave.domains.entities.canonical.calendar_query import CalendarRangeError + from airweave.models import Entity + + await seed(database, source) + _, fence = source + async with database() as db: + await db.execute( + update(Entity) + .where(Entity.sync_id == fence.sync_id, Entity.native_id == "all-day") + .values(source_payload={"oversized": "x" * (21 * 1024 * 1024)}) + ) + await db.commit() + async with database() as db: + with pytest.raises(CalendarRangeError): + await CalendarRangeService._bounded_rows( + db, fence.organization_id, fence.sync_id, "cal" + ) + assert not any(isinstance(item, Entity) for item in db.identity_map.values()) + + +async def test_empty_selection_reconciles_partial_parent_without_saved_cursor(database, source): + from airweave.domains.entities.canonical.tests.test_calendar_recovery import ( + NativeHTTP, + run, + setup, + ) + from airweave.platform.configs.config import GoogleCalendarConfig + + capture, fence = source + parent = record("calendar", {"id": "partial", "timeZone": "UTC"}) + child = record("event", {"id": "old", "status": "confirmed"}, "partial").model_copy( + update={"parent": parent.identity} + ) + async with database() as db: + result = await capture.capture(db, CaptureBatch(fence=fence, records=(parent, child))) + child_id = result.changes[-1].record.id + native = NativeHTTP([]) + pipeline, ctx, runtime, client = await setup( + database, source, native, config=GoogleCalendarConfig(calendar_ids=()) + ) + await run(pipeline, ctx, runtime) + async with database() as db: + current = await capture.store.read(db, fence.organization_id, fence.sync_id, child_id) + assert current.content_access == "unavailable" and current.payload == {} + assert current.removal_reason != "provider_deleted" + assert not native.calls + await client.aclose() + + +async def test_missing_selected_calendar_revokes_partial_parent_without_cursor(database, source): + from airweave.domains.entities.canonical.tests.test_calendar_recovery import ( + NativeHTTP, + run, + setup, + ) + from airweave.platform.configs.config import GoogleCalendarConfig + + capture, fence = source + parent = record("calendar", {"id": "missing", "timeZone": "UTC"}) + child = record("event", {"id": "retained", "status": "confirmed"}, "missing").model_copy( + update={"parent": parent.identity} + ) + async with database() as db: + initial = await capture.capture(db, CaptureBatch(fence=fence, records=(parent, child))) + child_id = initial.changes[-1].record.id + native = NativeHTTP( + [("/users/me/calendarList", {"items": []}), ("/users/me/calendarList/missing", (404, {}))] + ) + pipeline, ctx, runtime, client = await setup( + database, source, native, config=GoogleCalendarConfig(calendar_ids=("missing",)) + ) + with pytest.raises(ValueError, match="not accessible"): + await run(pipeline, ctx, runtime) + async with database() as db: + current = await capture.store.read(db, fence.organization_id, fence.sync_id, child_id) + assert current.content_access == "unavailable" and current.payload == {} + # Child cleanup may follow a later retry; recursive parent visibility hides it now. + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == fence.sync_id)) + assert cursor.cursor_data["canonical_cycle"]["phase"] == "active" + await client.aclose() diff --git a/backend/airweave/domains/entities/canonical/tests/test_calendar_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_calendar_recovery.py new file mode 100644 index 0000000000..c811dd0476 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_calendar_recovery.py @@ -0,0 +1,441 @@ +"""Real Calendar source and SQL page lifecycle; only provider HTTP is synthetic.""" + +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.calendar_query import CalendarRange, CalendarRangeService +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.entities.canonical.tests.test_mixed_scopes import next_job +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.sources.google_calendar import GoogleCalendarSource +from airweave.platform.sources.records.google_calendar import BASE + +CONFIG = GoogleCalendarConfig( + occurrence_window={"start": "2026-03-01T00:00:00Z", "end": "2026-04-01T00:00:00Z"} +) +MEMBERS = {"items": [{"id": "cal", "timeZone": "UTC"}]} + + +def event(native_id): + return { + "id": native_id, + "start": {"dateTime": "2026-03-08T09:00:00Z"}, + "end": {"dateTime": "2026-03-08T10:00:00Z"}, + } + + +class NativeHTTP: + def __init__(self, replies, principal="fixture-primary"): + self.principal = principal + self.replies, self.calls = list(replies), [] + + async def handle(self, request): + if request.url.path.endswith("/calendars/primary"): + return httpx.Response(200, json={"id": self.principal}) + self.calls.append((request.url.path, dict(request.url.params))) + path, value = self.replies.pop(0) + assert str(request.url).split("?")[0] == BASE + path + if isinstance(value, Exception): + raise value + status, payload = value if isinstance(value, tuple) else (200, value) + return httpx.Response(status, json=payload) + + +async def setup(database, source, native, attempt=1, config=CONFIG): + service, fence = source + config = config.model_copy(update={"expected_primary_calendar_id": "fixture-primary"}) + client = httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) + connector = await GoogleCalendarSource.create( + auth=StaticTokenProvider("synthetic"), logger=MagicMock(), http_client=client, config=config + ) + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + await pipeline.start(ctx) + return pipeline, ctx, runtime, client + + +async def no_limits(): + pass + + +async def run(pipeline, ctx, runtime): + await pipeline.run_scans(ctx, runtime, no_limits) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + await pipeline.save_checkpoint(ctx, runtime) + + +async def test_native_calendar_resume_delta_and_published_range(database, source): + # Pre-page-engine tokens cannot authorize a delta without exact scope evidence. + async with database() as db: + await source[0].save_checkpoint(db, source[1], {"calendar_tokens": {"cal": "legacy"}}) + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("a")], "nextPageToken": "p2"}), + ("/calendars/cal/events", ConnectionError("interrupted")), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("b")], "nextSyncToken": "full-token"}), + ("/calendars/cal/events", {"items": [event("occurrence")]}), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "delta-token"}), + ("/calendars/cal/events", {"items": [event("occurrence")]}), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + with pytest.raises(ConnectionError): + await run(pipeline, ctx, runtime) + await client.aclose() + pipeline, ctx, runtime, client = await setup(database, source, native, 2) + await run(pipeline, ctx, runtime) + assert native.calls[4][1]["pageToken"] == "p2" + async with database() as db: + result = await CalendarRangeService("secret").read( + db, + source[1].organization_id, + source[1].sync_id, + "cal", + CalendarRange(start="2026-03-08T00:00:00Z", end="2026-03-09T00:00:00Z", timezone="UTC"), + ) + assert result.records[0].identity.native_id == "occurrence" + assert result.refresh_state == "last_completed" + await client.aclose() + source = await next_job(database, source) + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + assert native.calls[7][1]["syncToken"] == "full-token" + async with database() as db: + masters = ( + await db.scalars( + select(Entity).where( + Entity.entity_definition_short_name == "event", Entity.deleted_at.is_(None) + ) + ) + ).all() + assert {row.native_id for row in masters} == {"a", "b"} + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + assert scan.execution_state["last_full"]["checkpoint"]["value"] == { + "sync_token": "full-token" + } + assert scan.execution_state["published"]["checkpoint"]["value"] == { + "sync_token": "delta-token" + } + assert not native.replies + await client.aclose() + + +async def test_native_410_restarts_only_one_master_scope(database, source): + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("kept")], "nextSyncToken": "old"}), + ("/calendars/cal/events", {"items": []}), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("delta-only")], "nextPageToken": "delta2"}), + ("/calendars/cal/events", (410, {"error": {"message": "Expired"}})), + ("/calendars/cal/events", {"items": [event("kept")], "nextSyncToken": "fresh"}), + ("/calendars/cal/events", {"items": []}), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + await client.aclose() + source = await next_job(database, source) + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + assert native.calls[5][1]["syncToken"] == "old" + assert "syncToken" not in native.calls[6][1] and "pageToken" not in native.calls[6][1] + async with database() as db: + stale = await db.scalar(select(Entity).where(Entity.native_id == "delta-only")) + assert stale.deleted_at is not None and stale.removal_reason == "absent" + await client.aclose() + + +async def test_config_change_selects_full_instead_of_rejecting_forever(database, source): + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "old"}), + ("/calendars/cal/events", {"items": []}), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "new"}), + ("/calendars/cal/events", {"items": []}), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + await client.aclose() + source = await next_job(database, source) + changed = CONFIG.model_copy(update={"calendar_ids": ("cal",)}) + pipeline, ctx, runtime, client = await setup(database, source, native, config=changed) + await run(pipeline, ctx, runtime) + assert "syncToken" not in native.calls[4][1] + await client.aclose() + + +@pytest.mark.parametrize("required", [False, True]) +async def test_occurrence_loss_withdraws_then_restoration_reacquires_unchanged_master( + database, source, required +): + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("unchanged")], "nextSyncToken": "discard"}), + ("/calendars/cal/events", (404, {})), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("unchanged")], "nextSyncToken": "restored"}), + ("/calendars/cal/events", {"items": []}), + ] + ) + from airweave.domains.entities.canonical.page_source import RequiredScopeAccessLost + + config = CONFIG.model_copy(update={"calendar_ids": ("cal",)}) if required else CONFIG + pipeline, ctx, runtime, client = await setup(database, source, native, config=config) + if required: + with pytest.raises(RequiredScopeAccessLost): + await run(pipeline, ctx, runtime) + else: + await run(pipeline, ctx, runtime) + async with database() as db: + entity = await db.scalar(select(Entity).where(Entity.native_id == "unchanged")) + record_id = entity.id + result = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, record_id + ) + assert result.content_access == "unavailable" and result.payload == {} + await client.aclose() + if not required: + source = await next_job(database, source) + pipeline, ctx, runtime, client = await setup( + database, source, native, 2 if required else 1, config + ) + await run(pipeline, ctx, runtime) + assert "syncToken" not in native.calls[4][1] + async with database() as db: + result = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, record_id + ) + assert result.content_access == "available" and result.payload == event("unchanged") + await client.aclose() + + +async def test_missing_selection_withdrawal_survives_interruption_then_restores(database, source): + native = NativeHTTP( + [ + ("/users/me/calendarList", {"items": []}), + ("/users/me/calendarList/cal", (404, {})), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("fresh")], "nextSyncToken": "new"}), + ("/calendars/cal/events", {"items": []}), + ] + ) + config = CONFIG.model_copy(update={"calendar_ids": ("cal",)}) + pipeline, ctx, runtime, client = await setup(database, source, native, config=config) + + async def stop_after_committed_page(): + async with database() as db: + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "calendar")) + if scan and scan.continuation.get("selection_unavailable"): + raise InterruptedError("Synthetic postcommit interruption") + + with pytest.raises(InterruptedError): + await pipeline.run_scans(ctx, runtime, stop_after_committed_page) + async with database() as db: + root = await db.scalar(select(Entity).where(Entity.native_id == "cal")) + assert root.deleted_at is not None and root.removal_reason == "scope_removed" + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "calendar")) + assert scan.phase == "collecting" and scan.continuation["selection_unavailable"] + await client.aclose() + pipeline, ctx, runtime, client = await setup(database, source, native, 2, config) + await run(pipeline, ctx, runtime) + assert "syncToken" not in native.calls[3][1] + assert not native.replies + await client.aclose() + + +async def test_failed_refresh_keeps_publication_and_new_writer_is_not_fresh(database, source): + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "old"}), + ("/calendars/cal/events", {"items": [event("old-occurrence")]}), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [], "nextSyncToken": "new"}), + ("/calendars/cal/events", {"items": [event("new-occurrence")], "nextPageToken": "p2"}), + ("/calendars/cal/events", ConnectionError("interrupted")), + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": []}), + ] + ) + query = CalendarRange(start="2026-03-08T00:00:00Z", end="2026-03-09T00:00:00Z", timezone="UTC") + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + async with database() as db: + scan = await db.scalar( + select(CaptureScan).where(CaptureScan.record_type == "event_occurrence") + ) + published = scan.execution_state["published"] + await client.aclose() + source = await next_job(database, source) + async with database() as db: + result = await CalendarRangeService("secret").read( + db, source[1].organization_id, source[1].sync_id, "cal", query + ) + assert result.refresh_state == "refresh_in_progress" + pipeline, ctx, runtime, client = await setup(database, source, native) + with pytest.raises(ConnectionError): + await run(pipeline, ctx, runtime) + async with database() as db: + scan = await db.scalar( + select(CaptureScan).where(CaptureScan.record_type == "event_occurrence") + ) + assert scan.execution_state["published"] == published + result = await CalendarRangeService("secret").read( + db, source[1].organization_id, source[1].sync_id, "cal", query + ) + assert result.refresh_state == "refresh_in_progress" + assert {item.identity.native_id for item in result.records} == { + "old-occurrence", + "new-occurrence", + } + await client.aclose() + pipeline, ctx, runtime, client = await setup(database, source, native, 2) + await run(pipeline, ctx, runtime) + async with database() as db: + result = await CalendarRangeService("secret").read( + db, source[1].organization_id, source[1].sync_id, "cal", query + ) + assert result.refresh_state == "last_completed" + assert {item.identity.native_id for item in result.records} == {"new-occurrence"} + scan = await db.scalar( + select(CaptureScan).where(CaptureScan.record_type == "event_occurrence") + ) + assert ( + scan.execution_state["published"]["observed_change_sequence"] + > published["observed_change_sequence"] + ) + await client.aclose() + + +async def test_membership_role_downgrade_hides_prior_event_details(database, source): + config = CONFIG.model_copy(update={"calendar_ids": ("cal",)}) + native = NativeHTTP( + [ + ("/users/me/calendarList", MEMBERS), + ("/calendars/cal/events", {"items": [event("private-detail")], "nextSyncToken": "old"}), + ("/calendars/cal/events", {"items": []}), + ( + "/users/me/calendarList", + {"items": [{"id": "cal", "timeZone": "UTC", "accessRole": "freeBusyReader"}]}, + ), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native, config=config) + await run(pipeline, ctx, runtime) + await client.aclose() + source = await next_job(database, source) + pipeline, ctx, runtime, client = await setup(database, source, native, config=config) + with pytest.raises(ValueError, match="not accessible"): + await run(pipeline, ctx, runtime) + async with database() as db: + original = await db.scalar(select(Entity).where(Entity.native_id == "private-detail")) + result = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, original.id + ) + assert result.content_access == "unavailable" and result.payload == {} + parent = await db.scalar(select(Entity).where(Entity.native_id == "cal")) + assert parent.removal_reason == "access_revoked" + assert not native.replies and len(native.calls) == 4 + await client.aclose() + + +async def test_reader_role_change_hides_children_until_same_identity_recaptured(database, source): + owner = {"items": [{"id": "cal", "timeZone": "UTC", "accessRole": "owner"}]} + reader = {"items": [{"id": "cal", "timeZone": "UTC", "accessRole": "reader"}]} + secret = {**event("private"), "visibility": "private", "description": "Formerly visible"} + redacted = {**event("private"), "visibility": "private"} + native = NativeHTTP( + [ + ("/users/me/calendarList", owner), + ("/calendars/cal/events", {"items": [secret], "nextSyncToken": "owner-token"}), + ("/calendars/cal/events", {"items": []}), + ("/users/me/calendarList", reader), + ("/users/me/calendarList", reader), + ("/calendars/cal/events", {"items": [redacted], "nextSyncToken": "reader-token"}), + ("/calendars/cal/events", {"items": []}), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + await run(pipeline, ctx, runtime) + async with database() as db: + child = await db.scalar(select(Entity).where(Entity.native_id == "private")) + child_id = child.id + parent = await db.scalar(select(Entity).where(Entity.native_id == "cal")) + old_epoch = parent.visibility_epoch + await client.aclose() + source = await next_job(database, source) + pipeline, ctx, runtime, client = await setup(database, source, native) + + async def stop_after_role_commit(): + async with database() as db: + parent = await db.scalar(select(Entity).where(Entity.native_id == "cal")) + if parent.visibility_epoch != old_epoch: + raise InterruptedError("Synthetic role-change commit boundary") + + with pytest.raises(InterruptedError): + await pipeline.run_scans(ctx, runtime, stop_after_role_commit) + async with database() as db: + hidden = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, child_id + ) + assert hidden.content_access == "unavailable" and hidden.payload == {} + await client.aclose() + pipeline, ctx, runtime, client = await setup(database, source, native, 2) + await run(pipeline, ctx, runtime) + assert "syncToken" not in native.calls[5][1] + async with database() as db: + current = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, child_id + ) + assert current.content_access == "available" and current.payload == redacted + parent = await db.scalar(select(Entity).where(Entity.native_id == "cal")) + assert parent.visibility_epoch == old_epoch + 1 + await client.aclose() + + +async def test_wrong_primary_identity_never_writes_capture(database, source): + from airweave.models.sync_cursor import SyncCursor + + native = NativeHTTP([], principal="wrong-primary") + async with database() as db: + before = [(row.id, row.cursor_data) for row in (await db.scalars(select(SyncCursor))).all()] + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) as client: + with pytest.raises(ValueError, match="does not match"): + await GoogleCalendarSource.create( + auth=StaticTokenProvider("same-broker-account"), + logger=MagicMock(), + http_client=client, + config=GoogleCalendarConfig(expected_primary_calendar_id="trusted-primary"), + ) + assert native.calls == [] + async with database() as db: + assert (await db.scalars(select(Entity))).all() == [] + after = [(row.id, row.cursor_data) for row in (await db.scalars(select(SyncCursor))).all()] + assert after == before diff --git a/backend/airweave/domains/entities/canonical/tests/test_capture_pipeline.py b/backend/airweave/domains/entities/canonical/tests/test_capture_pipeline.py new file mode 100644 index 0000000000..e4e87c41a3 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_capture_pipeline.py @@ -0,0 +1,184 @@ +"""Single-crawl capture path against PostgreSQL, independent of index services.""" + +import logging +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from temporalio.testing import ActivityEnvironment + +from airweave.adapters.event_bus.fake import FakeEventBus +from airweave.domains.entities.canonical.requests import CompletedScope, RemovedScope, StartedScope +from airweave.domains.entities.canonical.tests.helpers import observation +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt, resolve_capture_attempt +from airweave.domains.sync_pipeline.config import SyncConfig +from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime +from airweave.domains.sync_pipeline.exceptions import SyncFailureError +from airweave.domains.sync_pipeline.orchestrator import SyncOrchestrator +from airweave.domains.sync_pipeline.pipeline.entity_tracker import EntityTracker +from airweave.domains.sync_pipeline.stream import AsyncSourceStream +from airweave.domains.sync_pipeline.worker_pool import AsyncWorkerPool +from airweave.domains.syncs.cursors.cursor import SyncCursor +from airweave.models import Entity +from airweave.models import SyncCursor as StoredCursor + +pytestmark = pytest.mark.integration + + +def components(database, source): + service, fence = source + logger = logging.getLogger("canonical-capture-test") + config = SyncConfig() + config.behavior.skip_guardrails = True + ctx = SimpleNamespace( + organization_id=fence.organization_id, + sync_id=fence.sync_id, + sync_job_id=fence.job_id, + sync=SimpleNamespace(id=fence.sync_id), + sync_job=SimpleNamespace(id=fence.job_id), + organization=SimpleNamespace(id=fence.organization_id), + collection_id=uuid4(), + source_connection_id=uuid4(), + source_short_name="test", + execution_config=config, + force_full_sync=False, + logger=logger, + batch_size=1, + max_batch_latency_ms=0, + should_batch=True, + ) + bus = FakeEventBus() + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + ("event",), + CaptureAttempt(id=fence.attempt_id, number=fence.attempt_number), + ) + runtime = SyncRuntime( + source=SimpleNamespace(source_name="synthetic", supports_access_control=False), + entity_tracker=EntityTracker(fence.job_id, fence.sync_id, logger), + cursor=SyncCursor(fence.sync_id), + canonical_capture=pipeline, + ) + return ctx, pipeline, runtime, bus + + +def orchestrator(ctx, pipeline, runtime, generator, bus): + return SyncOrchestrator( + entity_pipeline=pipeline, + worker_pool=AsyncWorkerPool(logger=ctx.logger), + stream=AsyncSourceStream(generator, logger=ctx.logger), + sync_context=ctx, + runtime=runtime, + access_control_pipeline=AsyncMock(), + event_bus=bus, + usage_checker=AsyncMock(), + usage_ledger=AsyncMock(), + sync_cursor_service=AsyncMock(), + state_machine=AsyncMock(), + lifecycle_data=SimpleNamespace(), + sync_state_machine=AsyncMock(), + ) + + +async def test_original_payload_commits_without_index_and_preserves_stream_order(database, source): + ctx, pipeline, runtime, bus = components(database, source) + + async def generate(): + yield observation(payload={"native-only": {"nested": "retained"}, "version": 1}) + yield observation(payload={"native-only": {"nested": "retained"}, "version": 2}) + yield CompletedScope(record_type="event") + runtime.cursor.update(history_id="committed") + + runner = orchestrator(ctx, pipeline, runtime, generate(), bus) + await runner._start_sync() + await runner._process_entities() + await runner._cleanup_orphaned_entities_if_needed() + await runner._save_cursor_data() + async with database() as db: + row = (await db.scalars(select(Entity))).one() + assert row.source_payload == {"native-only": {"nested": "retained"}, "version": 2} + assert row.record_revision == 2 and row.indexed_revision is None + assert (await db.scalar(select(StoredCursor.cursor_data)))["history_id"] == "committed" + assert runtime.destinations == [] + assert len(bus.events) == 2 + + +async def test_failed_enumeration_keeps_partial_capture_but_no_cursor_or_orphan_delete( + database, source +): + ctx, pipeline, runtime, bus = components(database, source) + await pipeline.start(ctx) + await pipeline.process([observation("prior", "a")], ctx, runtime) + + async def generate(): + yield StartedScope(record_type="event", container_id="a") + yield observation("partial", "a") + runtime.cursor.update(history_id="must-not-save") + raise ConnectionError("provider page failed") + + runner = orchestrator(ctx, pipeline, runtime, generate(), bus) + await runner._start_sync() + with pytest.raises(ConnectionError, match="provider page failed"): + await runner._process_entities() + async with database() as db: + rows = list((await db.scalars(select(Entity))).all()) + assert "prior" in {row.native_id for row in rows} + assert all(row.deleted_at is None for row in rows) + assert await db.scalar(select(StoredCursor.id)) is None + + +async def test_same_attempt_rebaseline_and_confirmed_scope_loss(database, source): + ctx, pipeline, runtime, _ = components(database, source) + await pipeline.start(ctx) + await pipeline.process( + [ + observation("incremental-before-410", "a"), + observation("unrelated", "b"), + StartedScope(record_type="event", container_id="a"), + observation("full-after-reset", "a"), + CompletedScope(record_type="event", container_id="a"), + ], + ctx, + runtime, + ) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + async with database() as db: + rows = {row.native_id: row for row in (await db.scalars(select(Entity))).all()} + assert rows["incremental-before-410"].removal_reason == "absent" + assert rows["full-after-reset"].deleted_at is None + assert rows["unrelated"].deleted_at is None + await pipeline.process( + [ + RemovedScope( + record_type="event", + container_id="a", + removal_reason="access_revoked", + observed_at=datetime.now(timezone.utc), + ) + ], + ctx, + runtime, + ) + async with database() as db: + rows = {row.native_id: row for row in (await db.scalars(select(Entity))).all()} + assert rows["full-after-reset"].removal_reason == "access_revoked" + assert rows["unrelated"].deleted_at is None + with pytest.raises(SyncFailureError, match="after confirmed scope loss"): + await pipeline.process([observation("late", "a")], ctx, runtime) + + +def test_attempt_identity_uses_temporal_metadata_or_explicit_local_identity(): + with pytest.raises(ValueError, match="explicit CaptureAttempt"): + resolve_capture_attempt() + explicit = CaptureAttempt(id=uuid4(), number=3) + assert resolve_capture_attempt(explicit) == explicit + environment = ActivityEnvironment() + first = environment.run(resolve_capture_attempt) + second = environment.run(resolve_capture_attempt) + assert first == second and first.number == environment.info.attempt diff --git a/backend/airweave/domains/entities/canonical/tests/test_changes_cycles.py b/backend/airweave/domains/entities/canonical/tests/test_changes_cycles.py new file mode 100644 index 0000000000..06f5f261cb --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_changes_cycles.py @@ -0,0 +1,384 @@ +"""Real SQL: provider progress never substitutes for committed capture or inventory evidence.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, + ProviderCheckpoint, + RestartCycle, + TerminalCheckpoint, +) +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitScanPage, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.store import StaleWriter +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor + +CONFIG = CycleConfiguration(fingerprint="a" * 64, parents={"message": (None,)}) +SCOPE = CompletedScope(record_type="message") + + +def checkpoint(value): + return ProviderCheckpoint(value={"history_id": value}) + + +def record(native_id, **kwargs): + return CaptureRecord( + identity=RecordIdentity(record_type="message", native_id=native_id), + payload={"id": native_id}, + observed_at=datetime.now(timezone.utc), + **kwargs, + ) + + +async def begin(database, service, fence, **kwargs): + async with database() as db: + return await service.begin_cycle( + db, BeginCycle(fence=fence, configuration=CONFIG, **kwargs) + ) + + +async def start(database, service, fence, cycle): + async with database() as db: + previous = await service.read_scan(db, fence, SCOPE) + return await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=SCOPE, + cycle_id=cycle.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected=previous.version if previous else None, + ), + ) + + +async def page(database, service, fence, scan, *records, final=True, value=None): + async with database() as db: + result = await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + records=records, + continuation=ScanContinuation(value={"terminal": "200"}), + final=final, + provider_checkpoint=checkpoint( + value or ("300" if scan.mode == "changes" else "200") + ) + if final + else None, + ), + ) + return result.state + + +async def complete(database, service, fence, cycle, scan, value="200"): + async with database() as db: + cycle = await service.read_cycle(db, fence) + return await service.complete_cycle( + db, + CompleteCycle( + fence=fence, + expected=cycle.version, + terminal_checkpoint=TerminalCheckpoint( + record_type="message", expected=scan.version, checkpoint=checkpoint(value) + ), + ), + ) + + +async def baseline(database, service, fence): + cycle = await begin(database, service, fence, starting_checkpoint=checkpoint("100")) + scan = await start(database, service, fence, cycle) + scan = await page(database, service, fence, scan, record("A"), record("B")) + assert scan.phase == "reconciling" + async with database() as db: + result = await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + observed_at=datetime.now(timezone.utc), + ), + ) + return await complete(database, service, fence, cycle, result.state) + + +async def test_changes_commit_without_absence_and_promote_only_after_terminal(database, source): + service, fence = source + full = await baseline(database, service, fence) + delta = await begin(database, service, fence, mode="changes", expected=full.version) + assert delta.starting_checkpoint == checkpoint("200") + scan = await start(database, service, fence, delta) + scan = await page(database, service, fence, scan, record("A"), final=False) + async with database() as db: + state = await service.read_cycle(db, fence) + assert state.promoted_checkpoint == full.promoted_checkpoint + with pytest.raises(CycleConflict, match="incomplete"): + await complete(database, service, fence, delta, scan, "300") + with pytest.raises(ScanConflict, match="Changes"): + async with database() as db: + await service.scan_missing(db, fence, scan) + with pytest.raises(ScanConflict, match="Changes"): + async with database() as db: + await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + observed_at=datetime.now(timezone.utc), + ), + ) + scan = await page(database, service, fence, scan) + assert scan.phase == "complete" and scan.completed_at is not None + # A fresh session recovers after a crash between the final page and cycle publication. + async with database() as db: + scan = await service.read_scan(db, fence, SCOPE) + finished = await complete(database, service, fence, delta, scan, "300") + assert finished.last_full_capture == full.last_full_capture + assert finished.promoted_checkpoint.checkpoint == checkpoint("300") + assert await begin(database, service, fence, mode="changes") == finished # lost ACK + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + assert {r.native_id for r in rows if r.deleted_at is None} == {"A", "B"} + cursor = await db.scalar(select(SyncCursor)) + assert cursor.cursor_data["canonical_checkpoint"]["observed_change_sequence"] >= 2 + coverage = (await capture_coverage(db, fence.organization_id, (fence.sync_id,)))[ + fence.sync_id + ] + assert coverage.mode == "changes" and coverage.discovery == "scope_enumeration_complete" + assert "history_id" not in coverage.model_dump_json() + assert "configuration_digest" not in coverage.model_dump_json() + + +async def test_changes_rejects_no_baseline_wrong_start_and_changed_guarantee(database, source): + service, fence = source + with pytest.raises(CycleConflict, match="compatible"): + await begin(database, service, fence, mode="changes") + full = await baseline(database, service, fence) + with pytest.raises(CycleConflict, match="last promoted"): + await begin( + database, + service, + fence, + mode="changes", + expected=full.version, + starting_checkpoint=checkpoint("999"), + ) + changed = CONFIG.model_copy(update={"completion_policies": {"message": "discovery_only"}}) + with pytest.raises(CycleConflict, match="compatible"): + async with database() as db: + await service.begin_cycle( + db, + BeginCycle( + fence=fence, configuration=changed, expected=full.version, mode="changes" + ), + ) + + +async def test_terminal_version_and_lost_writer_cannot_publish(database, source): + service, fence = source + full = await baseline(database, service, fence) + delta = await begin(database, service, fence, mode="changes", expected=full.version) + first = await start(database, service, fence, delta) + final = await page(database, service, fence, first, record("A")) + with pytest.raises(CycleConflict, match="Terminal scan"): + await complete(database, service, fence, delta, first, "300") + async with database() as db: + newer = await service.activate_writer( + db, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + job_id=fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(StaleWriter): + await complete(database, service, fence, delta, final, "300") + async with database() as db: + state = await service.read_cycle(db, newer) + assert state.promoted_checkpoint == full.promoted_checkpoint + finished = await complete(database, service, newer, delta, final, "300") + assert finished.completed_job_id == newer.job_id + + +async def test_restart_preserves_successful_history_not_partial_delta(database, source): + service, fence = source + full = await baseline(database, service, fence) + delta = await begin(database, service, fence, mode="changes", expected=full.version) + scan = await start(database, service, fence, delta) + with pytest.raises(ScanConflict, match="never absence"): + await page( + database, service, fence, scan, record("A", kind="delete", removal_reason="absent") + ) + async with database() as db: + delta = await service.read_cycle(db, fence) + fresh = await service.restart_cycle( + db, + RestartCycle( + fence=fence, + expected=delta.version, + configuration=CONFIG, + starting_checkpoint=checkpoint("500"), + ), + ) + assert fresh.mode == "full" and fresh.starting_checkpoint == checkpoint("500") + assert fresh.promoted_checkpoint == full.promoted_checkpoint + assert fresh.last_full_capture == full.last_full_capture + + +async def test_exact_terminal_version_cannot_publish_different_checkpoint(database, source): + service, fence = source + full = await baseline(database, service, fence) + delta = await begin(database, service, fence, mode="changes", expected=full.version) + scan = await page(database, service, fence, await start(database, service, fence, delta)) + with pytest.raises(CycleConflict, match="does not match committed"): + await complete(database, service, fence, delta, scan, "forged") + async with database() as db: + persisted = await service.read_scan(db, fence, SCOPE) + state = await service.read_cycle(db, fence) + assert persisted.provider_checkpoint == checkpoint("300") + assert persisted.continuation.value == {"terminal": "200"} + assert state.promoted_checkpoint == full.promoted_checkpoint + + +async def test_full_refresh_without_checkpoint_clears_old_delta_authority(database, source): + service, fence = source + old = await baseline(database, service, fence) + fresh = await begin(database, service, fence, expected=old.version) + scan = await start(database, service, fence, fresh) + async with database() as db: + result = await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + records=(record("B"),), + continuation=ScanContinuation(), + final=True, + ), + ) + scan = result.state + async with database() as db: + await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + observed_at=datetime.now(timezone.utc), + ), + ) + current = await service.read_cycle(db, fence) + done = await service.complete_cycle( + db, CompleteCycle(fence=fence, expected=current.version) + ) + assert done.promoted_checkpoint is None + assert done.last_full_capture.cycle_id == fresh.version.cycle_id + with pytest.raises(CycleConflict, match="compatible"): + await begin(database, service, fence, mode="changes", expected=done.version) + async with database() as db: + assert ( + await db.scalar(select(Entity).where(Entity.native_id == "A")) + ).deleted_at is not None + + +async def test_incomplete_full_evidence_stays_incomplete_during_changes(database, source): + service, fence = source + config = CONFIG.model_copy(update={"completion_policies": {"message": "discovery_only"}}) + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + scan = await start(database, service, fence, cycle) + scan = await page(database, service, fence, scan, record("A")) + async with database() as db: + result = await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + observed_at=datetime.now(timezone.utc), + ), + ) + done = await complete(database, service, fence, cycle, result.state) + async with database() as db: + await service.begin_cycle( + db, BeginCycle(fence=fence, configuration=config, expected=done.version, mode="changes") + ) + coverage = (await capture_coverage(db, fence.organization_id, (fence.sync_id,)))[ + fence.sync_id + ] + assert coverage.phase == "active" and coverage.mode == "changes" + assert coverage.discovery == coverage.last_full_capture.discovery == "incomplete" + assert coverage.provider_checkpoint_promoted_at is not None + + +async def test_changes_forest_is_explicitly_unsupported(database, source): + service, fence = source + config = CycleConfiguration( + fingerprint="b" * 64, parents={"channel": (None,), "message": ("channel",)} + ) + with pytest.raises(CycleConflict, match="one independent root"): + async with database() as db: + await service.begin_cycle( + db, BeginCycle(fence=fence, configuration=config, mode="changes") + ) + + +@pytest.mark.parametrize("final, boundary", [(False, "early"), (True, None)]) +async def test_invalid_checkpoint_page_rolls_back_records_and_progress( + database, source, final, boundary +): + service, fence = source + full = await baseline(database, service, fence) + delta = await begin(database, service, fence, mode="changes", expected=full.version) + scan = await start(database, service, fence, delta) + with pytest.raises(ScanConflict, match="[Tt]erminal"): + async with database() as db: + await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=SCOPE, + cycle_id=scan.cycle_id, + expected=scan.version, + records=(record("uncommitted"),), + continuation=ScanContinuation(), + final=final, + provider_checkpoint=checkpoint(boundary) if boundary else None, + ), + ) + async with database() as db: + assert await service.read_scan(db, fence, SCOPE) == scan + assert await db.scalar(select(Entity.id).where(Entity.native_id == "uncommitted")) is None + state = await service.read_cycle(db, fence) + assert state.promoted_checkpoint == full.promoted_checkpoint diff --git a/backend/airweave/domains/entities/canonical/tests/test_checkpoint_driver.py b/backend/airweave/domains/entities/canonical/tests/test_checkpoint_driver.py new file mode 100644 index 0000000000..2a8a0badd7 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_checkpoint_driver.py @@ -0,0 +1,296 @@ +"""Actual driver/pipeline and PostgreSQL, with an explicitly synthetic native-page source.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration, ProviderCheckpoint +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidCaptureCheckpoint, +) +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.entities.canonical.tests.test_changes_cycles import record +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.entity import Entity + + +class NativeFixture: + canonical_record_types = ("message",) + canonical_container_parents = {} + capture_cycle_configuration = CycleConfiguration( + fingerprint="f" * 64, parents={"message": (None,)}, known_object_validation=("message",) + ) + + def __init__(self, *, stop=False, expired=0): + self.stop, self.expired = stop, expired + self.plans = [] + self.pages = [] + self.refreshes = [] + + async def prepare_cycle(self, previous): + self.plans.append(previous) + return CapturePlan( + starting_checkpoint=ProviderCheckpoint(value={"start": str(len(self.plans))}) + ) + + def initial_continuation(self, cycle): + return ScanContinuation( + value={"start": next(iter(cycle.starting_checkpoint.value.values())), "page": 0} + ) + + async def capture_page(self, scope, continuation, *, files, parent=None): + self.pages.append(continuation.value) + if self.expired: + self.expired -= 1 + raise InvalidCaptureCheckpoint("synthetic expired history") + if continuation.value["page"] == 0: + return CapturePage( + records=(record("listed"),), + continuation=ScanContinuation(value={**continuation.value, "page": 1}), + ) + if self.stop: + raise ConnectionError("synthetic interruption") + return CapturePage( + records=(), + continuation=ScanContinuation(value={**continuation.value, "page": 2}), + final=True, + provider_checkpoint=ProviderCheckpoint(value={"terminal": "done"}), + ) + + async def refresh_known(self, old, *, files): + self.refreshes.append(old.identity.native_id) + return record(old.identity.native_id) + + def child_scope(self, parent, record_type): + raise AssertionError("No child scope") + + async def confirm_absent(self, record): + raise AssertionError("Exact hydration must replace speculative absence") + + +def pipeline(database, source, connector, *, attempt=1): + service, fence = source + ctx, _, runtime, bus = components(database, source) + capture = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + runtime.canonical_capture = capture + return ctx, runtime, capture + + +async def test_actual_pipeline_resumes_plan_and_hydrates_known_omissions(database, source): + service, fence = source + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(record("omitted"),))) + connector = NativeFixture(stop=True) + ctx, runtime, capture = pipeline(database, source, connector) + await capture.start(ctx) + with pytest.raises(ConnectionError, match="interruption"): + await capture.run_scans(ctx, runtime, AsyncMock()) + connector.stop = False + ctx, runtime, capture = pipeline(database, source, connector, attempt=2) + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + assert len(connector.plans) == 1 + assert [p["page"] for p in connector.pages] == [0, 1, 1] + assert connector.refreshes == ["omitted"] + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + assert {r.native_id for r in rows if r.deleted_at is None} == {"listed", "omitted"} + cycle = await service.read_cycle(db, capture._writer()) + assert cycle.phase == "complete" + assert cycle.promoted_checkpoint.checkpoint.value == {"terminal": "done"} + assert cycle.last_full_capture.discovery == "scope_enumeration_complete" + + +@pytest.mark.parametrize("expirations", [1, 2]) +async def test_expired_checkpoint_restarts_whole_cycle_once(database, source, expirations): + connector = NativeFixture(expired=expirations) + ctx, runtime, capture = pipeline(database, source, connector) + await capture.start(ctx) + if expirations == 2: + with pytest.raises(InvalidCaptureCheckpoint): + await capture.run_scans(ctx, runtime, AsyncMock()) + else: + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + assert len(connector.plans) == 2 + assert [p["start"] for p in connector.pages[:2]] == ["1", "2"] + async with database() as db: + current = await source[0].read_cycle(db, capture._writer()) + assert current.starting_checkpoint.value == {"start": "2"} + assert current.phase == ("active" if expirations == 2 else "complete") + if expirations == 2: + assert current.promoted_checkpoint is None + + +async def test_driver_runs_changes_without_enumeration_or_omission_cleanup(database, source): + from airweave.domains.entities.canonical.cycle_models import BeginCycle + + connector = NativeFixture() + ctx, runtime, capture = pipeline(database, source, connector) + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + service, fence = source + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(record("not-in-delta"),))) + previous = await service.read_cycle(db, fence) + await service.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=connector.capture_cycle_configuration, + expected=previous.version, + mode="changes", + ), + ) + connector.pages.clear() + ctx, runtime, capture = pipeline(database, source, connector, attempt=2) + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + assert connector.refreshes == [] + assert len(connector.plans) == 1 + async with database() as db: + retained = await db.scalar(select(Entity).where(Entity.native_id == "not-in-delta")) + current = await service.read_cycle(db, capture._writer()) + assert retained.deleted_at is None + assert current.mode == "changes" and current.phase == "complete" + assert current.last_full_capture == previous.last_full_capture + + +async def test_exhaustive_known_validation_is_enforced_by_sql(database, source): + from datetime import datetime, timezone + + from airweave.domains.entities.canonical.cycle_models import BeginCycle + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitOmission, + CommitScanPage, + ReconcileScan, + ) + from airweave.domains.entities.canonical.scan_store import ScanConflict + + service, fence = source + config = NativeFixture.capture_cycle_configuration + scope = CompletedScope(record_type="message") + async with database() as db: + old = ( + (await service.capture(db, CaptureBatch(fence=fence, records=(record("omitted"),)))) + .changes[0] + .record + ) + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + state = await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + fingerprint=config.fingerprint, + ), + ) + state = ( + await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=scope, + cycle_id=state.cycle_id, + expected=state.version, + records=(), + continuation=ScanContinuation(), + final=True, + ), + ) + ).state + with pytest.raises(ScanConflict, match="still require"): + async with database() as db: + await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + async with database() as db: + refreshed = await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=old, observation=record("omitted") + ), + ) + result = await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=scope, + cycle_id=state.cycle_id, + expected=refreshed.state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + assert result.state.phase == "complete" and result.capture.changes == () + + +@pytest.mark.parametrize("force_flag", ["force_full_sync", "skip_load"]) +async def test_explicit_full_sync_restarts_delta_but_completed_job_retry_is_idempotent( + database, source, force_flag +): + from airweave.domains.entities.canonical.cycle_models import BeginCycle + + connector = NativeFixture() + ctx, runtime, capture = pipeline(database, source, connector) + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + service, fence = source + async with database() as db: + before = await service.read_cycle(db, fence) + delta = await service.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=connector.capture_cycle_configuration, + expected=before.version, + mode="changes", + ), + ) + ctx, runtime, capture = pipeline(database, source, connector, attempt=2) + ctx.force_full_sync = force_flag == "force_full_sync" + ctx.execution_config.cursor.skip_load = force_flag == "skip_load" + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + async with database() as db: + completed = await service.read_cycle(db, capture._writer()) + assert completed.mode == "full" and completed.version.cycle_id != delta.version.cycle_id + assert completed.starting_checkpoint.value == {"start": "2"} + pages = len(connector.pages) + ctx, runtime, capture = pipeline(database, source, connector, attempt=3) + ctx.force_full_sync = force_flag == "force_full_sync" + ctx.execution_config.cursor.skip_load = force_flag == "skip_load" + await capture.start(ctx) + await capture.run_scans(ctx, runtime, AsyncMock()) + await capture.save_checkpoint(ctx, runtime) + assert len(connector.pages) == pages and len(connector.plans) == 2 diff --git a/backend/airweave/domains/entities/canonical/tests/test_cycles.py b/backend/airweave/domains/entities/canonical/tests/test_cycles.py new file mode 100644 index 0000000000..ae2e18062c --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_cycles.py @@ -0,0 +1,425 @@ +"""Real PostgreSQL proofs for the single durable cycle boundary and scoped recovery.""" + +import asyncio +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, +) +from airweave.domains.entities.canonical.cycle_store import ( + CycleConflict, + complete_cycle, + scope_owner, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitScanPage, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.store import CanonicalStoreError, StaleWriter +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor + +CONFIG = CycleConfiguration( + fingerprint="a" * 64, root_record_type="channel", child_record_types=("message",) +) +ROOT = CompletedScope(record_type="channel") +CHILD = CompletedScope(record_type="message", container_id="C1") + + +def record(kind="channel", native_id="C1"): + parent = RecordIdentity(record_type="channel", native_id="C1") if kind == "message" else None + return CaptureRecord( + identity=RecordIdentity( + record_type=kind, native_id=native_id, container_id="C1" if parent else None + ), + parent=parent, + payload={"id": native_id}, + observed_at=datetime.now(timezone.utc), + ) + + +async def cycle(database, service, fence, **kwargs): + async with database() as db: + return await service.begin_cycle( + db, BeginCycle(fence=fence, configuration=CONFIG, **kwargs) + ) + + +async def scan(database, service, fence, active, scope=ROOT, **kwargs): + async with database() as db: + parent = await scope_owner(db, fence, active, scope) + kwargs.setdefault("expected_parent_epoch", parent.visibility_epoch if parent else None) + return await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=scope, + cycle_id=active.version.cycle_id, + fingerprint=CONFIG.fingerprint, + **kwargs, + ), + ) + + +async def page(database, service, fence, state, *records, final=False): + async with database() as db: + result = await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + records=records, + continuation=ScanContinuation(value={"next": "synthetic"}), + final=final, + ), + ) + return result.state + + +async def finish(database, service, fence, state): + async with database() as db: + result = await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + removal_reason="scope_removed" if state.scope == ROOT else "absent", + ), + ) + assert result.state.phase == "complete" + return result.state + + +async def refresh_cycle(database, service, fence): + async with database() as db: + return await service.read_cycle(db, fence) + + +async def complete(database, service, fence): + state = await refresh_cycle(database, service, fence) + async with database() as db: + return await service.complete_cycle(db, CompleteCycle(fence=fence, expected=state.version)) + + +async def fixture_scopes(database, service, fence, *, child_complete=True): + active = await cycle(database, service, fence) + root = await scan(database, service, fence, active) + root = await finish( + database, service, fence, await page(database, service, fence, root, record(), final=True) + ) + child = await scan(database, service, fence, active, CHILD) + child = await page( + database, service, fence, child, record("message", "one"), final=child_complete + ) + if child_complete: + child = await finish(database, service, fence, child) + return active, root, child + + +async def test_lost_final_ack_boundary_and_explicit_next_cycle(database, source): + service, fence = source + active, root, child = await fixture_scopes(database, service, fence) + async with database() as db: + assert "canonical_checkpoint" not in (await db.scalar(select(SyncCursor))).cursor_data + assert (await cycle(database, service, fence)).version.cycle_id == active.version.cycle_id + finished = await complete(database, service, fence) + assert finished.phase == "complete" + assert await cycle(database, service, fence) == finished + async with database() as db: + stored = (await db.scalar(select(SyncCursor))).cursor_data + assert stored["canonical_checkpoint"]["observed_change_sequence"] == 2 + following = await cycle(database, service, fence, expected=finished.version) + assert following.version.cycle_id != finished.version.cycle_id + with pytest.raises(CycleConflict, match="Root enumeration"): + await complete(database, service, fence) + refreshed = await scan(database, service, fence, following, expected=root.version) + assert refreshed.phase == "collecting" and refreshed.version.sweep_id != root.version.sweep_id + with pytest.raises(CycleConflict): + await page(database, service, fence, child, final=True) + + +async def test_retry_refreshes_root_without_resetting_child_pages(database, source): + service, fence = source + active, root, child = await fixture_scopes(database, service, fence, child_complete=False) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(StaleWriter): + await page(database, service, fence, child, record("message", "late")) + with pytest.raises(CycleConflict, match="Refresh"): + await page(database, service, newer, child, record("message", "early")) + with pytest.raises(CycleConflict, match="Restart root"): + await scan(database, service, newer, active) + root = await scan(database, service, newer, active, expected=root.version, restart=True) + await finish( + database, service, newer, await page(database, service, newer, root, record(), final=True) + ) + resumed = await scan(database, service, newer, active, CHILD) + assert resumed == child + await finish( + database, + service, + newer, + await page(database, service, newer, resumed, record("message", "two"), final=True), + ) + assert (await complete(database, service, newer)).phase == "complete" + async with database() as db: + messages = ( + await db.scalars(select(Entity).where(Entity.entity_definition_short_name == "message")) + ).all() + assert {row.native_id for row in messages} == {"one", "two"} + assert all(row.deleted_at is None for row in messages) + + +async def test_removed_parent_is_not_pending_and_reappearance_gets_new_scan(database, source): + service, fence = source + active, root, child = await fixture_scopes(database, service, fence, child_complete=False) + with pytest.raises(CycleConflict, match="incomplete child"): + await complete(database, service, fence) + root = await scan(database, service, fence, active, expected=root.version, restart=True) + root = await finish( + database, service, fence, await page(database, service, fence, root, final=True) + ) + with pytest.raises(CycleConflict, match="visible parent"): + await page(database, service, fence, child, final=True) + finished = await complete(database, service, fence) + following = await cycle(database, service, fence, expected=finished.version) + root = await scan(database, service, fence, following, expected=root.version) + await finish( + database, service, fence, await page(database, service, fence, root, record(), final=True) + ) + replacement = await scan(database, service, fence, following, CHILD, expected=child.version) + assert replacement.phase == "collecting" + assert replacement.version.sweep_id != child.version.sweep_id + with pytest.raises(CycleConflict): + await scan( + database, service, fence, active, CHILD, expected=replacement.version, restart=True + ) + + +async def test_configuration_unknown_scope_and_foreign_cycle_are_rejected(database, source): + service, fence = source + active = await cycle(database, service, fence) + async with database() as db: + with pytest.raises(CycleConflict, match="configuration changed"): + await service.begin_cycle( + db, + BeginCycle( + fence=fence, configuration=CONFIG.model_copy(update={"fingerprint": "b" * 64}) + ), + ) + with pytest.raises(CycleConflict, match="not declared"): + await scan(database, service, fence, active, CompletedScope(record_type="arbitrary")) + foreign = active.model_copy( + update={"version": active.version.model_copy(update={"cycle_id": uuid4()})} + ) + with pytest.raises(CycleConflict, match="active cycle"): + await scan(database, service, fence, foreign) + + +async def test_finalization_rollback_and_concurrent_cas(database, source): + service, fence = source + await fixture_scopes(database, service, fence) + active = await refresh_cycle(database, service, fence) + request = CompleteCycle(fence=fence, expected=active.version) + async with database() as db: + with pytest.raises(RuntimeError, match="after flush"): + async with UnitOfWork(db): + sync = await service.store._fenced_sync(db, fence) + await complete_cycle(db, sync, request) + raise RuntimeError("synthetic failure after flush") + assert await refresh_cycle(database, service, fence) == active + async with database() as db: + assert "canonical_checkpoint" not in (await db.scalar(select(SyncCursor))).cursor_data + + async def publish(): + async with database() as db: + return await service.complete_cycle(db, request) + + results = await asyncio.gather(publish(), publish(), return_exceptions=True) + assert sum(isinstance(result, CycleConflict) for result in results) == 1 + assert (await refresh_cycle(database, service, fence)).phase == "complete" + + +@pytest.mark.parametrize("finished", [False, True]) +async def test_legacy_checkpoint_cannot_erase_cycle(database, source, finished): + service, fence = source + await fixture_scopes(database, service, fence) + if finished: + await complete(database, service, fence) + before = await refresh_cycle(database, service, fence) + async with database() as db: + with pytest.raises(CanonicalStoreError, match="explicit cycle finalization"): + await service.save_checkpoint(db, fence, {"channel_ids": []}) + assert await refresh_cycle(database, service, fence) == before + + +@pytest.mark.parametrize("finished", [False, True]) +@pytest.mark.parametrize("operation", ["create", "update", "delete"]) +async def test_legacy_crud_cannot_erase_active_or_completed_cycle( + database, source, finished, operation +): + from airweave.core.context import BaseContext + from airweave.crud.crud_sync_cursor import sync_cursor + from airweave.schemas.organization import Organization + from airweave.schemas.sync_cursor import SyncCursorCreate + + service, fence = source + await fixture_scopes(database, service, fence) + if finished: + await complete(database, service, fence) + before = await refresh_cycle(database, service, fence) + now = datetime.now(timezone.utc) + ctx = BaseContext( + organization=Organization( + id=fence.organization_id, name="Synthetic owner", created_at=now, modified_at=now + ) + ) + async with database() as db: + with pytest.raises(ValueError, match="Cycle-owned"): + if operation == "create": + await sync_cursor.create_or_update( + db, sync_id=fence.sync_id, ctx=ctx, obj_in=SyncCursorCreate(cursor_data={}) + ) + elif operation == "update": + await sync_cursor.update_cursor_data( + db, sync_id=fence.sync_id, ctx=ctx, cursor_data={} + ) + else: + await sync_cursor.delete_by_sync_id(db, sync_id=fence.sync_id, ctx=ctx) + assert await refresh_cycle(database, service, fence) == before + + +async def test_child_pages_cannot_omit_visibility_parent_and_root_removal_is_private( + database, source +): + from airweave.domains.entities.canonical.scan_store import ScanConflict + + service, fence = source + active, root, child = await fixture_scopes(database, service, fence, child_complete=False) + with pytest.raises(ScanConflict, match="parent identity"): + await page( + database, + service, + fence, + child, + record("message", "unscoped").model_copy(update={"parent": None}), + ) + root = await scan(database, service, fence, active, expected=root.version, restart=True) + root = await page(database, service, fence, root, final=True) + async with database() as db: + result = await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=ROOT, + cycle_id=root.cycle_id, + expected=root.version, + observed_at=datetime.now(timezone.utc), + ), + ) + assert result.capture.changes[0].record.removal_reason == "scope_removed" + + +@pytest.mark.parametrize("completeness", ["partial", "metadata_only"]) +async def test_visible_metadata_parent_still_requires_child_scan(database, source, completeness): + service, fence = source + active = await cycle(database, service, fence) + root = await scan(database, service, fence, active) + root = await page( + database, + service, + fence, + root, + record().model_copy(update={"completeness": completeness}), + final=True, + ) + await finish(database, service, fence, root) + with pytest.raises(CycleConflict, match="incomplete child"): + await complete(database, service, fence) + + +async def test_explicit_restart_preserves_originals_and_last_checkpoint(database, source): + from airweave.domains.entities.canonical.cycle_models import RestartCycle + + service, fence = source + # A previous successful checkpoint must not be advanced by abandoning partial work. + async with database() as db: + await service.save_checkpoint(db, fence, {"previous_success": "synthetic"}) + active, root, child = await fixture_scopes(database, service, fence, child_complete=False) + active = await refresh_cycle(database, service, fence) + replacement_config = CONFIG.model_copy(update={"fingerprint": "b" * 64}) + request = RestartCycle(fence=fence, expected=active.version, configuration=replacement_config) + async with database() as db: + replacement = await service.restart_cycle(db, request) + assert replacement.version.cycle_id != active.version.cycle_id + assert replacement.phase == "active" + assert "root_writer_attempt_id" not in replacement.model_dump() + async with database() as db: + with pytest.raises(CycleConflict): + await service.restart_cycle(db, request) + with pytest.raises(CycleConflict): + await page(database, service, fence, child, final=True) + with pytest.raises(CycleConflict, match="Root enumeration"): + await complete(database, service, fence) + async with database() as db: + saved = (await db.scalar(select(SyncCursor))).cursor_data + assert saved["previous_success"] == "synthetic" + assert saved["canonical_checkpoint"]["observed_change_sequence"] == 0 + original = await db.scalar(select(Entity).where(Entity.native_id == "one")) + assert original.deleted_at is None and original.source_payload == {"id": "one"} + async with database() as db: + next_root = await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=ROOT, + cycle_id=replacement.version.cycle_id, + fingerprint=replacement_config.fingerprint, + expected=root.version, + ), + ) + assert next_root.phase == "collecting" + + +async def test_explicit_restart_rejects_completed_boundary_and_stale_revision(database, source): + from airweave.domains.entities.canonical.cycle_models import RestartCycle + + service, fence = source + active, _, _ = await fixture_scopes(database, service, fence) + async with database() as db: + with pytest.raises(CycleConflict, match="changed"): + await service.restart_cycle( + db, RestartCycle(fence=fence, expected=active.version, configuration=CONFIG) + ) + finished = await complete(database, service, fence) + async with database() as db: + with pytest.raises(CycleConflict, match="active cycle"): + await service.restart_cycle( + db, RestartCycle(fence=fence, expected=finished.version, configuration=CONFIG) + ) diff --git a/backend/airweave/domains/entities/canonical/tests/test_discovered_records.py b/backend/airweave/domains/entities/canonical/tests/test_discovered_records.py new file mode 100644 index 0000000000..4f8cf010df --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_discovered_records.py @@ -0,0 +1,284 @@ +"""Side discovery shares the page transaction, never another scope's sightings.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration +from airweave.domains.entities.canonical.cycle_store import scope_owner +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitScanPage, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.store import StaleWriter +from airweave.domains.entities.canonical.tests.helpers import observation +from airweave.models.entity import Entity +from airweave.models.entity_change import EntityChange + + +def original(kind, native): + return observation(native, identity=RecordIdentity(record_type=kind, native_id=native)) + + +async def begin(database, service, fence, cycle, scope): + async with database() as db: + parent = await scope_owner(db, fence, cycle, scope) + return await service.begin_scan( + db, + BeginScan( + fence=fence, + cycle_id=cycle.version.cycle_id, + scope=scope, + fingerprint=cycle.configuration.fingerprint, + expected_parent_epoch=parent.visibility_epoch if parent else None, + ), + ) + + +async def page(database, service, fence, state, records=(), discovered=()): + async with database() as db: + return await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + records=records, + discovered_records=discovered, + continuation=ScanContinuation(value={"cursor": "after"}), + final=True, + ), + ) + + +async def finish(database, service, fence, state): + async with database() as db: + return await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + + +async def setup(database, service, fence): + config = CycleConfiguration( + fingerprint="f" * 64, + parents={"page": (None,), "database": (None,), "block": ("page",)}, + completion_policies={"page": "discovery_only", "database": "discovery_only"}, + ) + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + state = await begin(database, service, fence, cycle, CompletedScope(record_type="page")) + captured = await page(database, service, fence, state, (original("page", "old"),)) + await finish(database, service, fence, captured.state) + old = captured.capture.changes[0].record + state = await begin(database, service, fence, cycle, CompletedScope(record_type="database")) + return cycle, state, old + + +async def test_completed_root_inventory_admits_new_frontier_without_changing_sightings( + database, source +): + service, fence = source + cycle, state, old = await setup(database, service, fence) + async with database() as db: + prior = (await db.get(Entity, old.id)).last_seen_run_id + result = await page( + database, + service, + fence, + state, + (original("database", "db"),), + (original("page", "old"), original("page", "new")), + ) + assert len(result.capture.changes) == 2 and result.capture.unchanged == 1 + await finish(database, service, fence, result.state) + child = await begin( + database, service, fence, cycle, CompletedScope(record_type="block", parent=old.identity) + ) + child = await page(database, service, fence, child) + await finish(database, service, fence, child.state) + async with database() as db: + assert (await db.get(Entity, old.id)).last_seen_run_id == prior + new = await db.scalar(select(Entity).where(Entity.native_id == "new")) + assert new.last_seen_run_id is None + work = await service.next_scope_work(db, fence, cycle.version.cycle_id) + assert work.record_type == "block" and work.parent.identity.native_id == "new" + with pytest.raises(ScanConflict): + await page(database, service, fence, state, discovered=(original("page", "again"),)) + + +@pytest.mark.parametrize( + "invalid", ["duplicate", "parent", "undeclared", "empty", "oversized", "promotion"] +) +async def test_rejected_side_discovery_preserves_page_cursor_and_journal(database, source, invalid): + service, fence = source + _, state, old = await setup(database, service, fence) + main = original("database", "db") + discovered = original("page", "new") + if invalid == "duplicate": + discovered = main + if invalid == "parent": + discovered = discovered.model_copy(update={"parent": old.identity}) + if invalid == "undeclared": + discovered = original("block", "new") + if invalid == "empty": + discovered = discovered.model_copy(update={"payload": {}}) + if invalid == "promotion": + discovered = discovered.model_copy(update={"allow_reparent": True}) + side = ( + tuple(original("page", str(i)) for i in range(500)) + if invalid == "oversized" + else (discovered,) + ) + with pytest.raises(ScanConflict): + await page(database, service, fence, state, (main,), side) + async with database() as db: + current = await service.read_scan(db, fence, state.scope) + assert current.version == state.version and current.continuation == state.continuation + assert await db.scalar(select(Entity).where(Entity.native_id == "db")) is None + assert await db.scalar(select(EntityChange).where(EntityChange.sequence > 1)) is None + + +async def test_new_writer_fences_entire_discovery_commit(database, source): + service, fence = source + _, state, _ = await setup(database, service, fence) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(StaleWriter): + await page( + database, + service, + fence, + state, + (original("database", "db"),), + (original("page", "new"),), + ) + async with database() as db: + assert await db.scalar(select(Entity).where(Entity.native_id == "new")) is None + assert (await service.read_scan(db, newer, state.scope)).version == state.version + + +async def test_existing_child_cannot_be_promoted_and_rolls_back_scoped_write(database, source): + from airweave.domains.entities.canonical.store import CanonicalStoreError + from airweave.domains.entities.canonical.tests.helpers import capture + + service, fence = source + _, state, old = await setup(database, service, fence) + # A prior connector mapping stored this native object beneath another page. + child = original("page", "child").model_copy(update={"parent": old.identity}) + await capture(database, service, fence, child) + with pytest.raises(CanonicalStoreError, match="parent"): + await page( + database, + service, + fence, + state, + (original("database", "db"),), + (original("page", "child"),), + ) + async with database() as db: + assert await db.scalar(select(Entity).where(Entity.native_id == "db")) is None + assert (await service.read_scan(db, fence, state.scope)).version == state.version + assert await db.scalar(select(EntityChange).where(EntityChange.sequence > 2)) is None + + +async def test_parent_withdrawn_after_io_blocks_scoped_and_discovered_records(database, source): + from airweave.domains.entities.canonical.cycle_store import CycleConflict + from airweave.domains.entities.canonical.tests.helpers import capture + + service, fence = source + cycle, root, old = await setup(database, service, fence) + final = await page(database, service, fence, root) + await finish(database, service, fence, final.state) + state = await begin( + database, service, fence, cycle, CompletedScope(record_type="block", parent=old.identity) + ) + await capture( + database, + service, + fence, + original("page", "old").model_copy( + update={"kind": "delete", "removal_reason": "access_revoked"} + ), + ) + body = original("block", "body").model_copy(update={"parent": old.identity}) + with pytest.raises(CycleConflict): + await page(database, service, fence, state, (body,), (original("page", "new"),)) + async with database() as db: + assert await db.scalar(select(Entity).where(Entity.native_id.in_(["new", "body"]))) is None + assert (await service.read_scan(db, fence, state.scope)).version == state.version + + +async def test_exhaustive_target_root_rejects_side_admission(database, source): + service, fence = source + config = CycleConfiguration( + fingerprint="f" * 64, + parents={"page": (None,), "database": (None,)}, + completion_policies={"database": "discovery_only"}, + ) + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + state = await begin(database, service, fence, cycle, CompletedScope(record_type="database")) + with pytest.raises(ScanConflict, match="independent declared roots"): + await page(database, service, fence, state, discovered=(original("page", "new"),)) + + +async def test_side_revival_invalidates_previously_completed_child_scope(database, source): + from airweave.domains.entities.canonical.tests.helpers import capture + + service, fence = source + cycle, root, old = await setup(database, service, fence) + final = await page(database, service, fence, root) + final = await finish(database, service, fence, final.state) + child = await begin( + database, service, fence, cycle, CompletedScope(record_type="block", parent=old.identity) + ) + child = await page(database, service, fence, child) + await finish(database, service, fence, child.state) + await capture( + database, + service, + fence, + original("page", "old").model_copy( + update={"kind": "delete", "removal_reason": "scope_removed"} + ), + ) + async with database() as db: + restarted = await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=root.scope, + cycle_id=root.cycle_id, + fingerprint=root.fingerprint, + expected=final.state.version, + restart=True, + ), + ) + revived = await page(database, service, fence, restarted, discovered=(original("page", "old"),)) + await finish(database, service, fence, revived.state) + async with database() as db: + work = await service.next_scope_work(db, fence, cycle.version.cycle_id) + assert work.parent.id == old.id + assert work.parent_visibility_epoch > child.state.parent_visibility_epoch + assert work.record_type == "block" diff --git a/backend/airweave/domains/entities/canonical/tests/test_document_http.py b/backend/airweave/domains/entities/canonical/tests/test_document_http.py new file mode 100644 index 0000000000..4206b5159c --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_document_http.py @@ -0,0 +1,186 @@ +"""Stored Docs HTTP reads use real SQL visibility and verified synthetic blob bytes.""" + +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from airweave.api import deps +from airweave.api.v1.endpoints.records import record_error_response, router +from airweave.db.session import get_db +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore, CanonicalStoreError +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.storage.exceptions import StorageNotFoundError + + +def document_capture(sync_id, variant): + contents = {} + + def blob(value, role=None): + content = json.dumps(value).encode() + digest = hashlib.sha256(content).hexdigest() + key = f"canonical/{sync_id}/blobs/sha256/{digest}" + contents[key] = content + return BlobReference( + key=key, + sha256=digest, + size_bytes=len(content), + media_type="application/json", + role=role, + ) + + document = { + "documentId": "wrong-file" if variant == "wrong_file" else "doc", + "title": "Retained document", + "futureField": {"preserve": [1, True]}, + "tabs": [{"tabProperties": {"tabId": "tab"}, "documentTab": {"body": {"content": []}}}], + } + native = blob(document) + manifest = blob( + { + "file_id": "doc", + "drive_version": "42", + "export": {"status": "unavailable", "reason": "unsupported"}, + "native": { + "status": "unavailable" + if variant == "native_unavailable" + else ("partial" if variant == "native_partial" else "complete"), + "document_blob": None if variant == "native_unavailable" else native.sha256, + "embedded_media": "not_retained", + "missing": [{"reason": "capture_budget"}] + if variant in {"native_partial", "native_unavailable"} + else [], + }, + }, + "representation_manifest", + ) + root = observation("folder", identity=RecordIdentity(record_type="folder", native_id="folder")) + record = observation( + "doc", + identity=RecordIdentity(record_type="file", native_id="doc"), + parent=root.identity, + payload={"id": "doc", "version": "42", "mimeType": "application/vnd.google-apps.document"}, + blobs=(native,) if variant == "missing_manifest" else (native, manifest), + completeness="partial", + ) + return root, record, contents, document + + +def document_storage(database, service, fence, root, document_record, contents, variant): + mutated = False + + async def read_bytes(key, *, max_bytes): + nonlocal mutated + if variant == "missing_bytes": + raise StorageNotFoundError("private-storage-key") + if variant == "corrupt_bytes": + return b"corrupt private content" + if not mutated: + mutated = True + update = None + if variant == "changed_during_read": + update = document_record.model_copy( + update={"payload": {**document_record.payload, "version": "43"}} + ) + elif variant == "deleted_during_read": + update = document_record.model_copy( + update={"kind": "delete", "removal_reason": "provider_deleted"} + ) + elif variant == "ancestor_revoked_during_read": + update = root.model_copy( + update={"kind": "delete", "removal_reason": "access_revoked"} + ) + if update is not None: + await capture(database, service, fence, update) + assert len(contents[key]) <= max_bytes + return contents[key] + + storage = AsyncMock() + storage.read_file.side_effect = read_bytes + return storage + + +@pytest.mark.parametrize( + "variant,status,code", + [ + ("available", 200, None), + ("missing_manifest", 409, "document_unavailable"), + ("wrong_file", 409, "document_unavailable"), + ("native_unavailable", 409, "document_unavailable"), + ("native_partial", 409, "document_incomplete"), + ("missing_bytes", 503, "blob_unavailable"), + ("corrupt_bytes", 503, "blob_unavailable"), + ("changed_during_read", 409, "stale_record_revision"), + ("deleted_during_read", 404, "record_not_found"), + ("ancestor_revoked_during_read", 404, "record_not_found"), + ("revoked_before_read", 404, "record_not_found"), + ("other_tenant", 404, "record_not_found"), + ("stale_revision", 409, "stale_record_revision"), + ("missing_revision", 422, None), + ], +) +async def test_document_read_authorization_integrity_and_native_contract( + database, source, variant, status, code +): + service, fence = source + root, document_record, contents, document = document_capture(fence.sync_id, variant) + saved = await capture(database, service, fence, root, document_record) + record_id = saved.changes[-1].record.id + if variant == "revoked_before_read": + await capture( + database, + service, + fence, + root.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + storage = document_storage(database, service, fence, root, document_record, contents, variant) + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def session(): + async with database() as db: + yield db + + owner = uuid4() if variant == "other_tenant" else fence.organization_id + app.dependency_overrides[deps.get_context] = lambda: SimpleNamespace( + organization=SimpleNamespace(id=owner) + ) + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(storage_backend=storage) + app.dependency_overrides[deps.get_canonical_query_service] = lambda: CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), "fixture" + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get( + f"/sync/{fence.sync_id}/records/{record_id}/document", + params={} + if variant == "missing_revision" + else {"revision": 2 if variant == "stale_revision" else 1}, + ) + assert response.status_code == status, response.text + if status == 200: + result = response.json() + assert result["document"] == document + assert result["id"] == str(record_id) + assert result["identity"]["native_id"] == "doc" + assert result["revision"] == 1 and result["observed_at"] + assert result["completeness"] == "partial" + assert result["manifest"]["native"]["status"] == "complete" + assert response.headers["cache-control"] == "private, no-store" + assert "key" not in result and "blobs" not in result + elif code: + assert response.json()["error"]["code"] == code + assert "private" not in response.text + assert "documentId" not in response.text + assert "canonical/" not in response.text + if variant in {"other_tenant", "revoked_before_read", "stale_revision", "missing_revision"}: + storage.read_file.assert_not_awaited() diff --git a/backend/airweave/domains/entities/canonical/tests/test_drive_lifecycle_probe.py b/backend/airweave/domains/entities/canonical/tests/test_drive_lifecycle_probe.py new file mode 100644 index 0000000000..5af3ba79d5 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_drive_lifecycle_probe.py @@ -0,0 +1,128 @@ +"""Actual lifecycle children verify the Drive page checkpoint contract offline.""" + +import json + +from sqlalchemy import text + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + +SCRIPT = r""" +import asyncio, json, os, sys +from contextlib import asynccontextmanager +from unittest.mock import MagicMock +sys.path.insert(0, 'tests/live') +import conftest +import httpx +import provider_lifecycle as lifecycle +from provider_sample import verify_rest_identity +from airweave.domains.entities.canonical.tests.test_drive_recovery import NativeHTTP, folder +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GoogleDriveConfig +from airweave.platform.sources.google_drive import GoogleDriveSource + +@asynccontextmanager +async def synthetic_source(name, account, expected_email, key, fence, **options): + assert name == 'google_drive' + responses = [] + if os.environ['TEST_STAGE'] == 'first': + responses += [('/changes/startPageToken', {'startPageToken': 'before'}), + ('/files', {'files': [{'id': 'folder-a'}]}), + ('/files/folder-a', folder('folder-a')), + ('/changes', {'changes': [], 'newStartPageToken': 'after'})] + else: + responses += [('/changes', {'changes': [], 'newStartPageToken': 'later'})] + native = NativeHTTP(responses) + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle), + event_hooks={'request': [options['request_hook']]}) as client: + connector = await GoogleDriveSource.create(auth=StaticTokenProvider('synthetic'), + logger=MagicMock(), http_client=client, + config=GoogleDriveConfig(expected_permission_id="principal-a")) + yield connector, 'provider_email' + assert not native.replies + +lifecycle.rest_source = synthetic_source +# All provider I/O is synthetic; reuse the disposable fixture (TCP in CI). +# The real account harness retains its private Unix-socket requirement. +lifecycle.harness.test_database_url = lambda: os.environ['CANONICAL_TEST_DATABASE_URL'] +os.environ['COMPOSIO_API_KEY'] = 'synthetic-test-only' +os.environ['LIVE_DRIVE_ACCOUNT_ID'] = 'synthetic-test-only' +os.environ['LIVE_EXPECTED_EMAIL'] = 'synthetic@example.test' +raise SystemExit(asyncio.run(lifecycle.child(json.loads(os.environ['TEST_MANIFEST'])))) +""" + + +async def test_drive_lifecycle_initial_then_fresh_process_changes(database, source, tmp_path): + fence = source[1] + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + manifest = { + "provider": "google_drive", + "schema": schema, + "root": str(tmp_path), + "organization_id": str(fence.organization_id), + "sync_id": str(fence.sync_id), + "record_limit": 10, + "request_limit": 20, + "timeout": 30, + "file_byte_limit": 1024, + "blob_byte_limit": 1024, + } + results = [] + for stage in ("first", "second"): + current = dict(manifest) + if stage == "first": + current.update(job_id=str(fence.job_id), attempt_number=2) + code, output, error = await subprocess_script( + SCRIPT, {"TEST_MANIFEST": json.dumps(current), "TEST_STAGE": stage} + ) + if code != 0: + raise AssertionError(output + error) + result = json.loads(output.splitlines()[-1]) + assert not result["failed"] and result["job_status"] == "completed" + assert result["stored_records"] == 1 and result["partial_records"] == 0 + results.append(result) + first, second = results + assert first["provider_requests"] == 5 and second["provider_requests"] == 2 + assert first["records_observed"] == 1 and second["records_observed"] == 0 + assert not first["loaded_durable_checkpoint"] and second["loaded_durable_checkpoint"] + assert first["resumed_changes_requests"] == 0 and second["resumed_changes_requests"] == 1 + assert first["observed_change_sequence"] == second["observed_change_sequence"] + assert first["payload_revision_digest"] == second["payload_revision_digest"] + + +async def test_checkpoint_validation_distinguishes_full_resume_from_changes(): + code, output, error = await subprocess_script( + r""" +import sys +from types import SimpleNamespace +sys.path.insert(0, 'tests/live') +import provider_lifecycle as lifecycle +attempt = SimpleNamespace(id="attempt") +for prior_phase, prior_mode, final_mode, changes_requests in ( + ("active", "full", "full", 0), + ("complete", "full", "changes", 1), + ("active", "changes", "changes", 1), +): + previous = {"canonical_cycle": {"phase": prior_phase, "mode": prior_mode}} + saved = { + "canonical_cycle": {"phase": "complete", "mode": final_mode, + "completed_job_id": "job", "last_full_capture": {"cycle_id": "full"}, + "promoted_checkpoint": {"checkpoint": {"value": {"page_token": "next"}}}}, + "canonical_checkpoint": {"writer_attempt_id": "attempt", "observed_change_sequence": 4}, + } + counters = {"started": 0, "completed": 0, "resumed_changes_requests": changes_requests} + lifecycle.validate_checkpoint("google_drive", {}, counters, saved, previous, True, attempt, 4) +# Loading a prior cursor must not excuse a missing promotion after completion. +saved["canonical_cycle"]["promoted_checkpoint"] = None +try: + lifecycle.validate_checkpoint("google_drive", {}, counters, saved, previous, True, attempt, 4) +except AssertionError: + pass +else: + raise AssertionError("Missing checkpoint promotion was accepted") +print("checkpoint modes verified") +""", + {}, + ) + assert code == 0, output + error + assert "checkpoint modes verified" in output diff --git a/backend/airweave/domains/entities/canonical/tests/test_drive_projection.py b/backend/airweave/domains/entities/canonical/tests/test_drive_projection.py new file mode 100644 index 0000000000..b52e048767 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_drive_projection.py @@ -0,0 +1,215 @@ +"""Drive projection preserves retained representations and explicit content gaps.""" + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.projection_models import ProjectionBinding, ProjectionWork +from airweave.domains.entities.canonical.projector import _select_inputs +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.platform.sources.records.workspace_manifest import ( + DOCS_MIME, + MANIFEST_MIME, + DocsGap, + DocsState, + ExportState, + WorkspaceManifestV1, + canonical_json, +) + + +def original(mime=DOCS_MIME): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type="file", native_id="doc"), + revision=1, + payload={"id": "doc", "version": "7", "name": "document", "mimeType": mime}, + payload_schema_version=1, + capture_hash="capture", + content_hash=None, + completeness="partial", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +def retain(source, content, media_type, *, role=None): + digest = hashlib.sha256(content).hexdigest() + return BlobReference( + key=f"canonical/{source.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type=media_type, + role=role, + ) + + +def docs_record(*, native_available): + source = original() + if native_available: + content = canonical_json( + { + "documentId": "doc", + "tabs": [ + { + "tabProperties": {"tabId": "tab"}, + "documentTab": { + "body": { + "content": [ + { + "paragraph": { + "elements": [ + {"textRun": {"content": "retained authored text"}}, + { + "inlineObjectElement": { + "inlineObjectId": "image" + } + }, + ] + } + } + ] + } + }, + } + ], + } + ) + ref = retain(source, content, "application/json") + native = DocsState( + status="complete", + document_blob=ref.sha256, + embedded_media="not_retained", + missing=( + DocsGap( + source_path="/tabs/0/documentTab/inlineObjects/image/embeddedObject", + reason="unsupported_media", + ), + ), + ) + export = ExportState(status="unavailable", reason="export_size_limit") + else: + content = b"retained DOCX bytes for the downstream converter" + ref = retain( + source, + content, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + native = DocsState( + status="unavailable", + embedded_media="not_retained", + missing=(DocsGap(reason="read_size_limit"),), + ) + export = ExportState(status="retained", blob=ref.sha256) + manifest = WorkspaceManifestV1(file_id="doc", drive_version="7", export=export, native=native) + raw = canonical_json(manifest.model_dump(mode="json")) + marked = retain(source, raw, MANIFEST_MIME, role="representation_manifest") + source = source.model_copy(update={"blobs": (ref, marked)}) + contents = {ref.key: content, marked.key: raw} + storage = AsyncMock() + storage.read_file.side_effect = lambda key, **kwargs: contents[key] + return source, storage, content + + +def coverage(result, source): + work = ProjectionWork( + binding=ProjectionBinding( + source_connection_id=uuid4(), source_name="google_drive", collection_id=uuid4() + ), + organization_id=uuid4(), + record=source, + pipeline_version=2, + previous_generation=None, + ) + return _select_inputs(result, work, "google_drive", uuid4(), lambda _: True)[1] + + +@pytest.mark.asyncio +async def test_docs_text_keeps_missing_media_in_extraction_parts(): + source, storage, _ = docs_record(native_available=True) + before = source.model_dump() + async with map_record(source, "google_drive", storage) as result: + assert len(result.parts) == 2 + assert "retained authored text" in Path(result.entities[0].local_path).read_text() + missing = result.parts[1] + assert missing.entity is None and missing.omission is None + assert missing.part.key == "/tabs/0/documentTab/inlineObjects/image/embeddedObject" + assert missing.part.part_index == 1 + evidence = coverage(result, source) + assert evidence.status == "partial" + assert [part.outcome for part in evidence.parts] == ["indexed", "unavailable_original"] + assert source.model_dump() == before + assert storage.read_file.await_count == 2 + + +@pytest.mark.asyncio +async def test_docs_uses_retained_export_when_native_capture_unavailable(): + source, storage, content = docs_record(native_available=False) + before = source.model_dump() + async with map_record(source, "google_drive", storage) as result: + entity = result.entities[0] + assert Path(entity.local_path).suffix == ".docx" + assert Path(entity.local_path).read_bytes() == content + assert result.parts[1].entity is None and result.parts[1].part.key == "/native" + assert coverage(result, source).status == "partial" + assert source.model_dump() == before and source.completeness == "partial" + assert storage.read_file.await_count == 2 + + +@pytest.mark.asyncio +async def test_unknown_extensionless_original_is_unsupported_not_conversion_failure(): + source = original("application/x-almanac-unknown-binary") + content = b"retained opaque original" + ref = retain(source, content, source.payload["mimeType"]) + source = source.model_copy(update={"blobs": (ref,), "completeness": "complete"}) + storage = AsyncMock() + storage.read_file.return_value = content + before = source.model_dump() + async with map_record(source, "google_drive", storage) as result: + assert len(result.parts) == 1 and not result.entities + assert result.parts[0].omission == "unsupported_format" + assert coverage(result, source).status == "unavailable" + assert source.model_dump() == before + storage.read_file.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_docs_without_native_or_export_publishes_only_unavailable_coverage(): + source = original() + manifest = WorkspaceManifestV1( + file_id="doc", + drive_version="7", + export=ExportState(status="unavailable", reason="export_size_limit"), + native=DocsState( + status="unavailable", + embedded_media="not_retained", + missing=(DocsGap(reason="read_size_limit"),), + ), + ) + raw = canonical_json(manifest.model_dump(mode="json")) + marked = retain(source, raw, MANIFEST_MIME, role="representation_manifest") + source = source.model_copy(update={"blobs": (marked,)}) + before = source.model_dump() + storage = AsyncMock() + storage.read_file.return_value = raw + async with map_record(source, "google_drive", storage) as result: + assert not result.entities + assert len(result.parts) == 1 and result.parts[0].part.part_index == 0 + evidence = coverage(result, source) + assert evidence.status == "unavailable" + assert evidence.parts[0].outcome == "unavailable_original" + assert source.model_dump() == before and source.completeness == "partial" + storage.read_file.assert_awaited_once() diff --git a/backend/airweave/domains/entities/canonical/tests/test_drive_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_drive_recovery.py new file mode 100644 index 0000000000..0fce356bd9 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_drive_recovery.py @@ -0,0 +1,260 @@ +"""Real canonical SQL/pipeline and Drive source; native HTTP is synthetic.""" + +from contextlib import aclosing +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob +from airweave.platform.configs.config import GoogleDriveConfig +from airweave.platform.sources.google_drive import GoogleDriveSource +from airweave.platform.sources.records.google_drive import BASE + + +class NativeHTTP: + def __init__(self, replies): + self.replies = list(replies) + self.calls = [] + + async def handle(self, request): + if request.url.path.endswith("/about"): + assert request.url.params["fields"] == "user(permissionId,me)" + return httpx.Response(200, json={"user": {"permissionId": "principal-a", "me": True}}) + self.calls.append((request.url.path, dict(request.url.params))) + path, value = self.replies.pop(0) + assert str(request.url).split("?")[0] == BASE + path + if isinstance(value, Exception): + raise value + status, payload = value if isinstance(value, tuple) else (200, value) + return httpx.Response(status, json=payload) + + +def folder(native_id): + return {"id": native_id, "mimeType": "application/vnd.google-apps.folder"} + + +async def setup(database, source, native, attempt=1): + service, fence = source + client = httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) + connector = await GoogleDriveSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GoogleDriveConfig(expected_permission_id="principal-a"), + ) + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + await pipeline.start(ctx) + return pipeline, ctx, runtime, client + + +async def no_limits(): + pass + + +async def run(pipeline, ctx, runtime): + await pipeline.run_scans(ctx, runtime, no_limits) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + await pipeline.save_checkpoint(ctx, runtime) + + +async def test_resume_pending_file_and_changes_then_lost_final_ack(database, source): + native = NativeHTTP( + [ + ("/changes/startPageToken", {"startPageToken": "before"}), + ("/files", {"files": [{"id": "a"}, {"id": "b"}]}), + ("/files/a", folder("a")), + ("/files/b", ConnectionError("interrupted")), + ("/files/b", folder("b")), + ( + "/changes", + { + "changes": [{"changeType": "file", "fileId": "a", "removed": True}], + "nextPageToken": "next", + }, + ), + ("/files/a", (404, {"error": {"message": "gone"}})), + ("/changes", {"changes": [], "newStartPageToken": "after"}), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + with pytest.raises(ConnectionError): + await run(pipeline, ctx, runtime) + async with database() as db: + cycle = await source[0].read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint is None + assert len((await db.scalars(select(Entity))).all()) == 1 + pipeline, ctx, runtime, client = await setup(database, source, native, 2) + async with aclosing(client): + await pipeline.run_scans(ctx, runtime, no_limits) + # Crash after final durable page, before publication. No provider call on retry. + pipeline, ctx, runtime, client = await setup(database, source, native, 3) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert not native.replies + assert sum(path.endswith("/files") for path, _ in native.calls) == 1 + assert sum(path.endswith("/startPageToken") for path, _ in native.calls) == 1 + async with database() as db: + cycle = await source[0].read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint.checkpoint.value == {"page_token": "after"} + records = {r.native_id: r for r in (await db.scalars(select(Entity))).all()} + assert records["a"].removal_reason == "scope_removed" + assert records["b"].deleted_at is None + + full_evidence = cycle.last_full_capture + service, fence = source + async with database() as db: + old = await db.get(SyncJob, fence.job_id) + old.status = "completed" + job_id = uuid4() + db.add( + SyncJob( + id=job_id, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + next_fence = await service.activate_writer( + db, fence.organization_id, fence.sync_id, job_id, attempt_id=uuid4(), attempt_number=1 + ) + delta = NativeHTTP( + [ + ( + "/changes", + { + "changes": [{"changeType": "file", "fileId": "a", "removed": True}], + "newStartPageToken": "later", + }, + ), + ("/files/a", folder("a")), + ] + ) + pipeline, ctx, runtime, client = await setup(database, (service, next_fence), delta) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert delta.calls[0][1]["pageToken"] == "after" + assert not delta.replies + async with database() as db: + cycle = await service.read_cycle(db, pipeline._writer()) + assert cycle.mode == "changes" and cycle.last_full_capture == full_evidence + assert cycle.promoted_checkpoint.checkpoint.value == {"page_token": "later"} + assert all(row.deleted_at is None for row in (await db.scalars(select(Entity))).all()) + + +async def test_drive_membership_restart_preserves_records_until_exact_validation(database, source): + native = NativeHTTP( + [ + ("/changes/startPageToken", {"startPageToken": "old"}), + ("/files", {"files": [{"id": "a"}]}), + ("/files/a", folder("a")), + ( + "/changes", + {"changes": [{"changeType": "drive", "driveId": "lost", "removed": True}]}, + ), + ("/changes/startPageToken", {"startPageToken": "fresh"}), + ("/files", {"files": []}), + ("/changes", {"changes": [], "newStartPageToken": "after"}), + ("/files/a", folder("a")), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert not native.replies + async with database() as db: + row = await db.scalar(select(Entity)) + assert row.deleted_at is None + cycle = await source[0].read_cycle(db, pipeline._writer()) + assert cycle.starting_checkpoint.value == {"page_token": "fresh"} + + +async def test_rejected_inventory_token_restarts_sweep_not_boundary(database, source): + native = NativeHTTP( + [ + ("/changes/startPageToken", {"startPageToken": "before"}), + ("/files", {"files": [{"id": "a"}], "nextPageToken": "invalid"}), + ("/files/a", folder("a")), + ( + "/files", + ( + 400, + { + "error": { + "errors": [ + { + "location": "pageToken", + "locationType": "parameter", + "reason": "invalid", + } + ] + } + }, + ), + ), + ("/files", {"files": [{"id": "b"}]}), + ("/files/b", folder("b")), + ("/changes", {"changes": [], "newStartPageToken": "after"}), + ("/files/a", (404, {"error": {"message": "gone"}})), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert not native.replies + assert sum(path.endswith("/startPageToken") for path, _ in native.calls) == 1 + async with database() as db: + assert ( + await db.scalar(select(Entity).where(Entity.native_id == "a")) + ).removal_reason == "scope_removed" + + +@pytest.mark.parametrize("failure", ["incomplete", "forbidden"]) +async def test_provider_failure_cannot_promote_or_remove_prior_capture(database, source, failure): + from airweave.domains.sources.exceptions import SourceEntityForbiddenError + + ending = ( + ("/files", {"incompleteSearch": True}) + if failure == "incomplete" + else ("/files/b", (403, {"error": {"message": "forbidden"}})) + ) + listing = ( + {"files": [{"id": "a"}], "nextPageToken": "second"} + if failure == "incomplete" + else {"files": [{"id": "a"}, {"id": "b"}]} + ) + native = NativeHTTP( + [ + ("/changes/startPageToken", {"startPageToken": "before"}), + ("/files", listing), + ("/files/a", folder("a")), + ending, + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + with pytest.raises(ValueError if failure == "incomplete" else SourceEntityForbiddenError): + await run(pipeline, ctx, runtime) + async with database() as db: + cycle = await source[0].read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint is None and cycle.phase == "active" + assert (await db.scalar(select(Entity))).deleted_at is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_early_child_work.py b/backend/airweave/domains/entities/canonical/tests/test_early_child_work.py new file mode 100644 index 0000000000..62690c2f4d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_early_child_work.py @@ -0,0 +1,143 @@ +"""Early exact-owner work is not completed inventory or a replacement discovery queue.""" + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import BeginCycle, CompleteCycle +from airweave.domains.entities.canonical.cycle_store import CycleConflict, next_scope_work +from airweave.domains.entities.canonical.requests import CompletedScope, parent_container_key +from airweave.domains.entities.canonical.scan_models import BeginScan +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.entities.canonical.tests.test_cycles import finish, page, scan +from airweave.domains.entities.canonical.tests.test_exact_parent_validation import CONFIG +from airweave.domains.entities.canonical.tests.test_forest_scans import collect, item +from airweave.models.entity import Entity + + +async def setup(database, source): + service, fence = source + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=CONFIG)) + channel = item("channel", "C") + await collect(database, service, fence, cycle, CompletedScope(record_type="channel"), channel) + message = item("message", "M", channel.identity) + scope = CompletedScope( + record_type="message", container_id=message.identity.container_id, parent=channel.identity + ) + state = await scan(database, service, fence, cycle, scope) + state = await page(database, service, fence, state, message) + async with database() as db: + work = await next_scope_work(db, fence, cycle.version.cycle_id, within=scope) + assert work.record_type == "file" and work.parent.identity == message.identity + request = BeginScan( + fence=fence, + scope=CompletedScope( + record_type="file", + container_id=parent_container_key(message.identity), + parent=message.identity, + ), + cycle_id=cycle.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected_parent_epoch=work.parent_visibility_epoch, + expected_parent_revision=work.parent.revision, + exact_parent_observation=message, + ) + return cycle, channel, message, scope, state, request + + +async def test_early_file_commits_without_completing_history(database, source): + service, fence = source + cycle, _, message, scope, history, request = await setup(database, source) + with pytest.raises(CycleConflict, match="fresh exact"): + async with database() as db: + await service.begin_scan( + db, request.model_copy(update={"exact_parent_observation": None}) + ) + async with database() as db: + admitted = await service.admit_scan(db, request) + state = await page( + database, service, fence, admitted.state, item("file", "F", message.identity), final=True + ) + await finish(database, service, fence, state) + async with database() as db: + assert await next_scope_work(db, fence, cycle.version.cycle_id, within=scope) is None + current = await service.read_scan(db, fence, scope) + assert current.phase == "collecting" and current.version == history.version + refreshed = await service.read_cycle(db, fence) + with pytest.raises(CycleConflict, match="incomplete child"): + async with database() as db: + await service.complete_cycle(db, CompleteCycle(fence=fence, expected=refreshed.version)) + + +async def test_unseen_and_other_inventory_owners_cannot_enter_early_work(database, source): + service, fence = source + cycle, channel, _, scope, _, request = await setup(database, source) + unseen = item("message", "unseen", channel.identity) + await capture(database, service, fence, unseen) + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.native_id == "unseen")) + forged = request.model_copy( + update={ + "scope": CompletedScope( + record_type="file", + container_id=parent_container_key(unseen.identity), + parent=unseen.identity, + ), + "expected_parent_revision": row.record_revision, + "expected_parent_epoch": row.visibility_epoch, + "exact_parent_observation": unseen, + } + ) + with pytest.raises(CycleConflict, match="ancestor membership"): + async with database() as db: + await service.admit_scan(db, forged) + # Same kind/container with a different owner is not a broad frontier query. + wrong = scope.model_copy( + update={"parent": channel.identity.model_copy(update={"native_id": "other"})} + ) + with pytest.raises(CycleConflict, match="parent is missing"): + async with database() as db: + await next_scope_work(db, fence, cycle.version.cycle_id, within=wrong) + + +async def test_early_receipt_invalidated_by_edit_and_inventory_restart(database, source): + service, fence = source + cycle, _, message, scope, history, request = await setup(database, source) + async with database() as db: + admitted = await service.admit_scan(db, request) + history = await page( + database, + service, + fence, + history, + message.model_copy(update={"payload": {"id": "M", "edited": True}}), + ) + with pytest.raises(CycleConflict, match="fresh exact"): + await page(database, service, fence, admitted.state, final=True) + await scan(database, service, fence, cycle, scope, expected=history.version, restart=True) + async with database() as db: + assert await next_scope_work(db, fence, cycle.version.cycle_id, within=scope) is None + with pytest.raises(CycleConflict, match="ancestor membership"): + await page(database, service, fence, admitted.state, final=True) + + +async def test_new_writer_cannot_use_early_parent_before_root_refresh(database, source): + from uuid import uuid4 + + service, fence = source + cycle, _, _, scope, _, request = await setup(database, source) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(CycleConflict, match="ancestor membership"): + async with database() as db: + await next_scope_work(db, newer, cycle.version.cycle_id, within=scope) + with pytest.raises(CycleConflict, match="ancestor membership"): + async with database() as db: + await service.admit_scan(db, request.model_copy(update={"fence": newer})) diff --git a/backend/airweave/domains/entities/canonical/tests/test_exact_parent_validation.py b/backend/airweave/domains/entities/canonical/tests/test_exact_parent_validation.py new file mode 100644 index 0000000000..a83af6669f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_exact_parent_validation.py @@ -0,0 +1,271 @@ +"""Fenced exact owner reads authorize child work without resetting discovery.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import CompletedScope, parent_container_key +from airweave.domains.entities.canonical.scan_models import BeginScan +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.entities.canonical.tests.test_cycles import finish, page +from airweave.domains.entities.canonical.tests.test_forest_scans import collect, item, work +from airweave.models.entity import Entity + +CONFIG = CycleConfiguration( + fingerprint="a" * 64, + parents={"channel": (None,), "message": ("channel",), "file": ("message",)}, + exact_parent_validation=("message",), +) + + +async def setup(database, source): + service, fence = source + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=CONFIG)) + channel = item("channel", "C") + message = item("message", "M", channel.identity) + await collect(database, service, fence, cycle, CompletedScope(record_type="channel"), channel) + await collect( + database, + service, + fence, + cycle, + CompletedScope( + record_type="message", + container_id=message.identity.container_id, + parent=channel.identity, + ), + message, + ) + selected = await work(database, service, fence, cycle) + owner = selected.parent + scope = CompletedScope( + record_type="file", + container_id=parent_container_key(message.identity), + parent=message.identity, + ) + request = BeginScan( + fence=fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected_parent_epoch=selected.parent_visibility_epoch, + expected_parent_revision=owner.revision, + exact_parent_observation=message, + ) + return cycle, message, owner, request + + +async def test_receipt_preserves_sighting_and_completed_children(database, source): + service, fence = source + cycle, message, owner, request = await setup(database, source) + async with database() as db: + seen = await db.scalar(select(Entity.last_seen_run_id).where(Entity.id == owner.id)) + admitted = await service.admit_scan(db, request) + assert admitted.parent == owner + assert admitted.state.parent_verified_attempt_id == fence.attempt_id + state = await page(database, service, fence, admitted.state, final=True) + state = await finish(database, service, fence, state) + async with database() as db: + assert await db.scalar(select(Entity.last_seen_run_id).where(Entity.id == owner.id)) == seen + repeated = await service.admit_scan( + db, + request.model_copy( + update={ + "expected": state.version, + "exact_parent_observation": None, + } + ), + ) + assert repeated.state.phase == "complete" + assert await work(database, service, fence, cycle) is None + + +async def test_missing_receipt_and_stale_post_io_revision_fail(database, source): + service, fence = source + _, message, _, request = await setup(database, source) + with pytest.raises(CycleConflict, match="fresh exact"): + async with database() as db: + await service.begin_scan( + db, request.model_copy(update={"exact_parent_observation": None}) + ) + await capture( + database, service, fence, message.model_copy(update={"payload": {"id": "M", "new": True}}) + ) + with pytest.raises(ScanConflict, match="owner changed"): + async with database() as db: + await service.admit_scan(db, request) + + +async def test_unavailable_observation_commits_without_child_admission(database, source): + service, fence = source + _, message, owner, request = await setup(database, source) + unavailable = message.model_copy(update={"kind": "delete", "removal_reason": "scope_removed"}) + async with database() as db: + result = await service.admit_scan( + db, request.model_copy(update={"exact_parent_observation": unavailable}) + ) + assert result.state is None + assert result.parent.deleted_at is not None + async with database() as db: + assert (await db.get(Entity, owner.id)).deleted_at is not None + + +async def test_new_writer_needs_root_refresh_before_exact_admission(database, source): + service, fence = source + _, _, _, request = await setup(database, source) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(CycleConflict): + async with database() as db: + await service.admit_scan(db, request.model_copy(update={"fence": newer})) + + +async def test_owner_edit_invalidates_receipt_and_restarts_inventory(database, source): + service, fence = source + _, message, owner, request = await setup(database, source) + async with database() as db: + admitted = await service.admit_scan(db, request) + state = admitted.state + changed = message.model_copy( + update={ + "payload": {"id": "M", "files": ["new"]}, + "descendant_visibility_fields": ("files",), + } + ) + await capture(database, service, fence, changed) + with pytest.raises(CycleConflict, match="fresh exact"): + await page(database, service, fence, state, final=True) + async with database() as db: + current = await db.get(Entity, owner.id) + refreshed = await service.admit_scan( + db, + request.model_copy( + update={ + "expected": state.version, + "expected_parent_epoch": current.visibility_epoch, + "expected_parent_revision": current.record_revision, + "exact_parent_observation": changed, + } + ), + ) + assert refreshed.state.version.sweep_id != state.version.sweep_id + assert refreshed.parent.payload["files"] == ["new"] + assert refreshed.state.parent_verified_revision == refreshed.parent.revision + # Native cursor expiry may restart the sweep, retaining this still-valid receipt. + async with database() as db: + restarted = await service.begin_scan( + db, + request.model_copy( + update={ + "expected": refreshed.state.version, + "restart": True, + "expected_parent_epoch": refreshed.state.parent_visibility_epoch, + "expected_parent_revision": refreshed.parent.revision, + "exact_parent_observation": None, + } + ), + ) + await page(database, service, fence, restarted, final=True) + + +async def test_stale_writer_cannot_apply_observation(database, source): + from airweave.domains.entities.canonical.store import StaleWriter + + service, fence = source + _, message, owner, request = await setup(database, source) + async with database() as db: + await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(StaleWriter): + async with database() as db: + await service.admit_scan( + db, + request.model_copy( + update={ + "exact_parent_observation": message.model_copy( + update={"payload": {"bad": True}} + ), + } + ), + ) + async with database() as db: + current = await db.get(Entity, owner.id) + assert current.source_payload == message.payload + assert current.record_revision == owner.revision + + +async def test_new_writer_reverifies_completed_children_without_recapture(database, source): + service, fence = source + cycle, message, _, request = await setup(database, source) + async with database() as db: + admitted = await service.admit_scan(db, request) + original = item("file", "F", message.identity) + state = await page(database, service, fence, admitted.state, original, final=True) + state = await finish(database, service, fence, state) + async with database() as db: + file_before = await db.scalar(select(Entity).where(Entity.native_id == "F")) + revision, payload = file_before.record_revision, file_before.source_payload + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + assert (await work(database, service, newer, cycle)).record_type == "channel" + root_scope = CompletedScope(record_type="channel") + async with database() as db: + old_root = await service.read_scan(db, newer, root_scope) + root = await service.begin_scan( + db, + BeginScan( + fence=newer, + scope=root_scope, + cycle_id=cycle.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected=old_root.version, + restart=True, + ), + ) + root = await page(database, service, newer, root, item("channel", "C"), final=True) + await finish(database, service, newer, root) + pending = await work(database, service, newer, cycle) + assert pending.record_type == "file" # Completed history survives; receipt does not. + async with database() as db: + refreshed = await service.admit_scan( + db, + request.model_copy( + update={ + "fence": newer, + "expected": state.version, + "expected_parent_revision": pending.parent.revision, + "expected_parent_epoch": pending.parent_visibility_epoch, + } + ), + ) + assert refreshed.state.phase == "complete" + assert refreshed.state.version.sweep_id == state.version.sweep_id + assert refreshed.state.parent_verified_attempt_id == newer.attempt_id + assert await work(database, service, newer, cycle) is None + async with database() as db: + current = await db.scalar(select(Entity).where(Entity.native_id == "F")) + assert (current.record_revision, current.source_payload) == (revision, payload) diff --git a/backend/airweave/domains/entities/canonical/tests/test_extraction_coverage.py b/backend/airweave/domains/entities/canonical/tests/test_extraction_coverage.py new file mode 100644 index 0000000000..81c4d6ea0f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_extraction_coverage.py @@ -0,0 +1,472 @@ +"""Real SQL and conversion; synthetic originals, embeddings and remote feed.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import fitz +import pytest +from sqlalchemy import select, update + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.core.logging import logger +from airweave.domains.converters.registry import ConverterRegistry +from airweave.domains.embedders.fakes.embedder import FakeDenseEmbedder, FakeSparseEmbedder +from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, +) +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + current_extraction, + publication_matches, +) +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.domains.entities.canonical.tests.test_gmail_projection import part +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.search.owned_models import OwnedSearchRequest +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.models import Entity, Sync +from airweave.models.projection_generation import ProjectionGeneration +from airweave.platform.destinations.vespa.transformer import EntityTransformer + + +def original(*attachments, completeness="complete"): + return observation( + identity=RecordIdentity(record_type="message", native_id="m1"), + completeness=completeness, + payload={ + "id": "m1", + "threadId": "t1", + "internalDate": "1000", + "payload": { + "mimeType": "multipart/mixed", + "parts": [ + part(b"Intact fundraising email body with useful context."), + *attachments, + ], + }, + }, + ) + + +def destination(collection_id): + result = MagicMock(collection_id=collection_id, feed_prepared=AsyncMock()) + result.prepare_documents = lambda chunks: { + "base_entity": [ + EntityTransformer(collection_id=result.collection_id).transform(c) for c in chunks + ] + } + return result + + +def projector(database, storage): + return CanonicalProjector( + CanonicalProjectionStore(), + database, + ChunkEmbedProcessor(ConverterRegistry(), FakeDenseEmbedder(), FakeSparseEmbedder()), + storage, + ) + + +async def test_message_pdf_video_and_missing_part_publish_truthful_coverage( + database, source, tmp_path +): + service, fence = source + binding = await bind_projection(database, fence, "gmail") + pdf = fitz.open() + page = pdf.new_page() + page.insert_text( + (40, 40), "Retained PDF investment discussion and detailed fundraising plans. " * 4 + ) + data = pdf.tobytes() + pdf.close() + item = original( + part(data, mime="application/pdf", filename="deck.pdf"), + part(b"synthetic video bytes", mime="video/mp4", filename="meeting.mp4"), + { + "mimeType": "application/pdf", + "filename": "large.pdf", + "body": {"attachmentId": "missing", "size": 500000000}, + }, + completeness="partial", + ) + item.payload["payload"]["parts"][0] = part( + "Intact fundraising email body: नमस्ते — café".encode(), + headers=[{"name": "Content-Type", "value": "text/plain; charset=gb2312"}], + ) + await capture(database, service, fence, item) + store = CanonicalProjectionStore() + target = destination(binding.collection_id) + result = await projector(database, FilesystemBackend(tmp_path)).batch( + fence.organization_id, fence.sync_id, "gmail", target, logger + ) + assert result.published == 1 and result.failed == 0 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + assert coverage.status == "partial" + assert [p.outcome for p in coverage.parts] == [ + "indexed", + "indexed", + "unsupported", + "unavailable_original", + ] + assert coverage.parts[1].key == "/payload/parts/1" + recovery = coverage.parts[0].charset_recoveries[0] + assert recovery.source_path == "/payload/parts/0" + assert recovery.from_charset == "gb2312" and recovery.to_charset == "utf-8" + assert ( + coverage.parts[1].kind == "file" and coverage.parts[1].media_type == "application/pdf" + ) + assert row.completeness == "partial" and row.source_payload == item.payload + assert not await store.pending(db, fence.organization_id, fence.sync_id) + locator = ProjectionLocator( + record_id=row.id, + revision=row.record_revision, + pipeline_version=row.indexed_pipeline_version, + generation=row.indexed_generation, + part_index=1, + ) + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + == row.id + ) + assert ( + await db.scalar( + select(Entity.id) + .join(Sync) + .where(publication_matches(locator.model_copy(update={"part_index": 2}))) + ) + is None + ) + ctx = MagicMock() + ctx.organization.id = fence.organization_id + counts = await OwnedSearchService._coverage( + db, ctx, OwnedSearchRequest(query="fundraising", sync_ids=(fence.sync_id,)) + ) + assert counts[0].partially_indexed_records == 1 and counts[0].pending_records == 0 + # New native revision withdraws every old part and its coverage before new extraction. + await capture( + database, service, fence, original(part(b"new video", mime="video/mp4", filename="new.mp4")) + ) + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + is None + ) + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + is None + ) + assert ( + await projector(database, FilesystemBackend(tmp_path)).batch( + fence.organization_id, fence.sync_id, "gmail", target, logger + ) + ).published == 1 + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + is None + ) + old = await db.get(ProjectionGeneration, locator.generation) + assert old.retired_at is not None + + +async def test_supported_attachment_conversion_failure_and_feed_failure_stay_pending( + database, source, tmp_path +): + service, fence = source + binding = await bind_projection(database, fence, "gmail") + await capture( + database, + service, + fence, + original(part(b"broken binary\x00", mime="application/pdf", filename="broken.pdf")), + ) + target = destination(binding.collection_id) + project = projector(database, FilesystemBackend(tmp_path)) + assert ( + await project.batch(fence.organization_id, fence.sync_id, "gmail", target, logger) + ).failed == 1 + target.feed_prepared.assert_not_awaited() + await capture( + database, service, fence, original(part(b"video", mime="video/mp4", filename="clip.mp4")) + ) + target.feed_prepared.side_effect = ConnectionError("synthetic interruption") + assert ( + await project.batch(fence.organization_id, fence.sync_id, "gmail", target, logger) + ).failed == 1 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.indexed_generation is None + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + is None + ) + assert ( + len(await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id)) + == 1 + ) + target.feed_prepared.side_effect = None + assert ( + await project.batch(fence.organization_id, fence.sync_id, "gmail", target, logger) + ).published == 1 + + +async def test_unsupported_only_publication_is_explicit_and_retries_on_pipeline_upgrade( + database, source, tmp_path +): + service, fence = source + binding = await bind_projection(database, fence, "google_drive") + from airweave.domains.entities.canonical.tests.test_gmail_projection import blob, record + + captured = record({}, sync_id=fence.sync_id) + ref = blob(captured, b"video bytes") + storage = FilesystemBackend(tmp_path) + await storage.write_file(ref.key, b"video bytes") + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type="file", native_id="video"), + payload={"id": "video", "name": "movie.mp4", "mimeType": "video/mp4"}, + blobs=(ref,), + ), + ) + target = destination(binding.collection_id) + assert ( + await projector(database, storage).batch( + fence.organization_id, fence.sync_id, "google_drive", target, logger + ) + ).published == 1 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.indexed_chunk_count == 0 and row.completeness == "complete" + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + ).status == "unavailable" + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(index_pipeline_version=2) + ) + await db.commit() + assert ( + len(await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id)) + == 1 + ) + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + is None + ) + + +def test_coverage_rejects_missing_duplicate_and_contradictory_parts(): + one = ExtractionOutcome(part_index=0, key="/body", kind="body", outcome="indexed") + for parts in [(one, one), (one.model_copy(update={"part_index": 1}),)]: + with pytest.raises(ValueError): + ExtractionCoverage(parts=parts) + with pytest.raises(ValueError): + ExtractionOutcome(part_index=0, key="a", kind="file", outcome="unsupported", reason=None) + + +async def test_coverage_manifest_is_exact_immutable_and_capture_fences_publication( + database, source +): + from airweave.domains.entities.canonical.projection_models import ( + ProjectionDocument, + scope_projection_document_id, + ) + + service, fence = source + binding = await bind_projection(database, fence, "gmail") + await capture(database, service, fence, original()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + generation, collection = uuid4(), binding.collection_id + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ) + documents = ( + ProjectionDocument( + schema_name="base_entity", + document_id=scope_projection_document_id( + fence.sync_id, collection, f"Entity_{locator.encode()}__chunk_0" + ), + ), + ) + indexed = ExtractionOutcome(part_index=0, key="/body", kind="body", outcome="indexed") + async with database() as db: + with pytest.raises(ValueError, match="exactly"): + await store.prepare( + db, work, generation, collection, documents, coverage=ExtractionCoverage(parts=()) + ) + assert await store.prepare( + db, + work, + generation, + collection, + documents, + coverage=ExtractionCoverage(parts=(indexed,)), + ) + with pytest.raises(ValueError, match="immutable"): + await store.prepare( + db, + work, + generation, + collection, + documents, + coverage=ExtractionCoverage( + parts=(indexed.model_copy(update={"key": "different"}),) + ), + ) + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, work.record.id, work.record.revision + ) + is None + ) + await capture( + database, service, fence, original(part(b"video", mime="video/mp4", filename="clip.mp4")) + ) + async with database() as db: + assert not await store.publish(db, work, generation, 1) + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, work.record.id, work.record.revision + ) + is None + ) + + +async def test_inline_image_without_ocr_keeps_body_partial_but_converter_failure_is_fatal( + database, source, tmp_path +): + service, fence = source + binding = await bind_projection(database, fence, "gmail") + item = original(part(b"retained image bytes", mime="image/png", filename="inline.png")) + item.payload["payload"]["mimeType"] = "multipart/related" + await capture(database, service, fence, item) + storage = FilesystemBackend(tmp_path) + target = destination(binding.collection_id) + result = await projector(database, storage).batch( + fence.organization_id, fence.sync_id, "gmail", target, logger + ) + assert result.published == 1 and result.failed == 0 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + assert coverage.status == "partial" + assert [p.outcome for p in coverage.parts] == ["indexed", "unsupported"] + assert coverage.parts[1].media_type == "image/png" + assert row.indexed_chunk_count > 0 + assert row.source_payload == item.payload + # Enabling OCR changes the pipeline; an actual converter failure must not + # masquerade as an unsupported-format success or preserve stale coverage. + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(index_pipeline_version=2) + ) + await db.commit() + ocr = MagicMock(convert_batch=AsyncMock(side_effect=lambda paths: dict.fromkeys(paths))) + configured = CanonicalProjector( + CanonicalProjectionStore(), + database, + ChunkEmbedProcessor(ConverterRegistry(ocr), FakeDenseEmbedder(), FakeSparseEmbedder()), + storage, + ) + target.feed_prepared.reset_mock() + failed = await configured.batch(fence.organization_id, fence.sync_id, "gmail", target, logger) + assert failed.failed == 1 and failed.published == 0 + ocr.convert_batch.assert_awaited_once() + target.feed_prepared.assert_not_awaited() + async with database() as db: + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + is None + ) + + +async def test_drive_metadata_only_reports_missing_original_then_new_bytes_are_indexed( + database, source, tmp_path +): + from airweave.domains.entities.canonical.tests.test_gmail_projection import blob, record + + service, fence = source + binding = await bind_projection(database, fence, "google_drive") + identity = RecordIdentity(record_type="file", native_id="retained-file") + payload = {"id": identity.native_id, "name": "Notes.txt", "mimeType": "text/plain"} + metadata = observation(identity=identity, payload=payload, completeness="metadata_only") + await capture(database, service, fence, metadata) + storage = FilesystemBackend(tmp_path) + target = destination(binding.collection_id) + project = projector(database, storage) + assert ( + await project.batch(fence.organization_id, fence.sync_id, "google_drive", target, logger) + ).published == 1 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + assert coverage.status == "unavailable" and row.indexed_chunk_count == 0 + assert coverage.parts[0].outcome == "unavailable_original" + assert coverage.parts[0].reason == "original_not_captured" + content = b"Recovered original text contains the complete available document." + ref = blob(record({}, sync_id=fence.sync_id), content).model_copy( + update={"media_type": "text/plain"} + ) + await storage.write_file(ref.key, content) + await capture( + database, service, fence, observation(identity=identity, payload=payload, blobs=(ref,)) + ) + assert ( + await project.batch(fence.organization_id, fence.sync_id, "google_drive", target, logger) + ).published == 1 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.indexed_chunk_count > 0 + previous = ProjectionLocator( + record_id=row.id, + revision=row.record_revision, + pipeline_version=row.indexed_pipeline_version, + generation=row.indexed_generation, + part_index=0, + ) + # Losing retained body availability withdraws the prior text generation. + await capture(database, service, fence, metadata) + assert ( + await project.batch(fence.organization_id, fence.sync_id, "google_drive", target, logger) + ).published == 1 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.indexed_chunk_count == 0 + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(previous))) + is None + ) + assert ( + await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + ).status == "unavailable" diff --git a/backend/airweave/domains/entities/canonical/tests/test_forest_scans.py b/backend/airweave/domains/entities/canonical/tests/test_forest_scans.py new file mode 100644 index 0000000000..38fa7dfd84 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_forest_scans.py @@ -0,0 +1,342 @@ +"""Real SQL frontier and identity proofs for nested source inventories.""" + +from datetime import datetime, timezone + +import pytest + +from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, + parent_container_key, +) +from airweave.domains.entities.canonical.tests.test_cycles import complete, finish, page, scan + + +def test_source_owned_parent_digest_is_bounded_and_distinguishes_full_identity(): + left = RecordIdentity(record_type="block", container_id="left", native_id="same") + right = RecordIdentity(record_type="block", container_id="right", native_id="same") + assert parent_container_key(left) != parent_container_key(right) + for depth in range(96): + key = parent_container_key(left) + assert len(key) == 71 + left = RecordIdentity(record_type="block", container_id=key, native_id=str(depth)) + assert parent_container_key(left) == parent_container_key( + RecordIdentity.model_validate_json(left.model_dump_json()) + ) + + +def item(kind, native, parent=None): + return CaptureRecord( + identity=RecordIdentity( + record_type=kind, + native_id=native, + container_id=parent_container_key(parent) if parent else None, + ), + parent=parent, + payload={"id": native}, + observed_at=datetime.now(timezone.utc), + ) + + +async def work(database, service, fence, active): + async with database() as db: + return await service.next_scope_work(db, fence, active.version.cycle_id) + + +async def collect(database, service, fence, active, scope, *records): + state = await scan(database, service, fence, active, scope) + state = await page(database, service, fence, state, *records, final=True) + return await finish(database, service, fence, state) + + +async def test_nested_frontier_discovers_descendants_and_requires_every_scope(database, source): + service, fence = source + config = CycleConfiguration.from_source( + fingerprint="a" * 64, + record_types=("repository", "issue", "comment", "file"), + container_parents={"issue": "repository", "comment": "issue", "file": "repository"}, + ) + async with database() as db: + active = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + assert (await work(database, service, fence, active)).record_type == "repository" + repo = item("repository", "repo") + await collect(database, service, fence, active, CompletedScope(record_type="repository"), repo) + issue_work = await work(database, service, fence, active) + assert issue_work.record_type == "issue" and issue_work.parent.identity == repo.identity + issue = item("issue", "1", repo.identity) + issue_scope = CompletedScope( + record_type="issue", container_id=issue.identity.container_id, parent=repo.identity + ) + await collect(database, service, fence, active, issue_scope, issue) + next_item = await work(database, service, fence, active) + assert next_item.record_type == "comment" and next_item.parent.identity == issue.identity + comment = item("comment", "1", issue.identity) + await collect( + database, + service, + fence, + active, + CompletedScope( + record_type="comment", container_id=comment.identity.container_id, parent=issue.identity + ), + comment, + ) + assert (await work(database, service, fence, active)).record_type == "file" + await collect( + database, + service, + fence, + active, + CompletedScope( + record_type="file", + container_id=parent_container_key(repo.identity), + parent=repo.identity, + ), + ) + assert await work(database, service, fence, active) is None + assert (await complete(database, service, fence)).phase == "complete" + + +def test_single_source_topology_accepts_same_kind_and_upgrades_old_flat_snapshot(): + config = CycleConfiguration.from_source( + fingerprint="a" * 64, + record_types=("page", "block"), + container_parents={"block": ("page", "block")}, + ) + assert config.children_of("block") == ("block",) + assert config.root_record_types == ("page",) + old = CycleConfiguration( + fingerprint="a" * 64, root_record_type="page", child_record_types=("block",) + ) + assert old.parents == {"page": (None,), "block": ("page",)} + with pytest.raises(ValueError, match="reachable"): + CycleConfiguration(fingerprint="a" * 64, parents={"block": ("block",)}) + + +@pytest.mark.parametrize("database", ["scan_upgrade"], indirect=True) +async def test_upgrade_preserves_old_acknowledged_scan_and_cycle_restart_cas(database): + from uuid import uuid4 + + from sqlalchemy import select + + from airweave.domains.entities.canonical.cycle_models import RestartCycle + from airweave.domains.entities.canonical.cycle_store import CycleConflict + from airweave.domains.entities.canonical.service import CanonicalCaptureService + from airweave.domains.entities.canonical.store import CanonicalRecordStore + from airweave.models import SyncJob + from airweave.models.capture_scan import CaptureScan + + service = CanonicalCaptureService(CanonicalRecordStore()) + async with database() as db: + job = await db.scalar(select(SyncJob)) + job.status = "running" + await db.commit() + fence = await service.activate_writer( + db, job.organization_id, job.sync_id, job.id, attempt_id=uuid4(), attempt_number=1 + ) + async with database() as db: + cycle = await service.read_cycle(db, fence) + assert cycle.version.revision == 7 and cycle.configuration.parents == { + "block": (None,), + "leaf": ("block",), + } + scope = CompletedScope(record_type="leaf", container_id="active-root") + async with database() as db: + state = await service.read_scan(db, fence, scope) + assert state.version.revision == 11 + assert state.continuation.value == {"cursor": "synthetic-acknowledged-page"} + assert state.scope.parent == RecordIdentity(record_type="block", native_id="active-root") + async with database() as db: + restarted = await service.restart_cycle( + db, RestartCycle(fence=fence, expected=cycle.version, configuration=cycle.configuration) + ) + assert restarted.version.cycle_id != cycle.version.cycle_id + async with database() as db: + old = await db.scalar(select(CaptureScan)) + assert old.sweep_id == state.version.sweep_id and old.revision == 11 + assert old.continuation == state.continuation.value + async with database() as db: + with pytest.raises(CycleConflict): + await service.restart_cycle( + db, + RestartCycle( + fence=fence, expected=cycle.version, configuration=cycle.configuration + ), + ) + + +async def test_driver_finds_new_same_kind_descendants_behind_uuid_frontier( + database, source, monkeypatch +): + from unittest.mock import AsyncMock, MagicMock + from uuid import UUID, uuid4 + + from airweave.domains.entities.canonical.page_source import CapturePage + from airweave.domains.entities.canonical.scan_models import ScanContinuation + from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver + + service, fence = source + ids = iter([UUID(int=100), UUID(int=50), UUID(int=1)]) + monkeypatch.setattr("airweave.domains.entities.canonical.store.uuid4", lambda: next(ids)) + + class NestedSource: + canonical_record_types = ("block",) + canonical_container_parents = {"block": (None, "block")} + capture_cycle_configuration = CycleConfiguration.from_source( + fingerprint="a" * 64, + record_types=canonical_record_types, + container_parents=canonical_container_parents, + ) + + def __init__(self): + self.calls = [] + self.interrupt = True + + def child_scope(self, parent, record_type): + return CompletedScope( + record_type=record_type, + parent=parent.identity, + container_id=parent_container_key(parent.identity), + ) + + async def capture_page(self, scope, continuation, *, files, parent=None): + name = parent.identity.native_id if parent else None + self.calls.append(name) + if name == "leaf" and self.interrupt: + self.interrupt = False + raise RuntimeError("Synthetic interruption before final descendant scan") + next_name = {None: "root", "root": "child", "child": "leaf"}.get(name) + records = (item("block", next_name, scope.parent),) if next_name else () + return CapturePage(records=records, continuation=ScanContinuation(), final=True) + + async def confirm_absent(self, record): + raise AssertionError("No omitted records in this synthetic tree") + + connector = NestedSource() + driver = CanonicalScanDriver( + service, database, fence, connector, AsyncMock(), AsyncMock(), MagicMock() + ) + with pytest.raises(RuntimeError, match="Synthetic interruption"): + await driver.run() + async with database() as db: + before = await service.read_cycle(db, fence) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + resumed = CanonicalScanDriver( + service, database, newer, connector, AsyncMock(), AsyncMock(), MagicMock() + ) + after = await resumed.run() + assert before.version.cycle_id == after.version.cycle_id + assert connector.calls == [None, "root", "child", "leaf"] * 2 + assert (await complete(database, service, newer)).phase == "complete" + + +async def test_inflight_page_rejected_after_exact_parent_epoch_changes(database, source): + from airweave.domains.entities.canonical.requests import CaptureBatch + from airweave.domains.entities.canonical.scan_store import ScanConflict + + service, fence = source + config = CycleConfiguration.from_source( + fingerprint="a" * 64, + record_types=("repository", "issue"), + container_parents={"issue": "repository"}, + ) + async with database() as db: + active = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + repo = item("repository", "repo") + await collect(database, service, fence, active, CompletedScope(record_type="repository"), repo) + issue = item("issue", "one", repo.identity) + scope = CompletedScope( + record_type="issue", parent=repo.identity, container_id=issue.identity.container_id + ) + inflight = await scan(database, service, fence, active, scope) + from airweave.domains.entities.canonical.cycle_store import CycleConflict + + with pytest.raises(CycleConflict, match="identity mapping changed"): + await scan( + database, service, fence, active, scope.model_copy(update={"container_id": "different"}) + ) + async with database() as db: + await service.capture( + db, + CaptureBatch( + fence=fence, + records=( + repo.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ), + ), + ) + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(repo,))) + with pytest.raises(ScanConflict, match="owner changed"): + await page(database, service, fence, inflight, issue, final=True) + async with database() as db: + saved = await service.read_scan(db, fence, scope) + assert saved.version == inflight.version and saved.continuation == inflight.continuation + restarted = await scan( + database, service, fence, active, scope, expected=saved.version, restart=True + ) + assert restarted.parent_visibility_epoch != inflight.parent_visibility_epoch + await finish( + database, service, fence, await page(database, service, fence, restarted, issue, final=True) + ) + assert (await complete(database, service, fence)).phase == "complete" + + +def test_legacy_topology_root_collision_and_oversized_kind_are_rejected(): + with pytest.raises(ValueError, match="Ambiguous"): + CycleConfiguration( + fingerprint="a" * 64, root_record_type="block", child_record_types=("block",) + ) + with pytest.raises(ValueError): + CycleConfiguration(fingerprint="a" * 64, parents={"a" * 201: (None,)}) + + +@pytest.mark.parametrize( + "database", + [ + "scan_upgrade_children_object", + "scan_upgrade_foreign_version", + "scan_upgrade_completed", + "scan_upgrade_too_many_children", + ], + indirect=True, +) +async def test_upgrade_never_guesses_ownership_from_invalid_or_inactive_cycle(database): + from sqlalchemy import select + + from airweave.models.capture_scan import CaptureScan + + async with database() as db: + row = await db.scalar(select(CaptureScan)) + assert row.parent_record_id is None and row.parent_visibility_epoch is None + assert row.scope_key == '["leaf","active-root"]' + assert row.revision == 11 and row.continuation == {"cursor": "synthetic-acknowledged-page"} + + +@pytest.mark.parametrize("database", ["scan_upgrade_default_children"], indirect=True) +async def test_upgrade_accepts_historical_omitted_child_list_default(database): + from sqlalchemy import select + + from airweave.models.capture_scan import CaptureScan + from airweave.models.sync_cursor import SyncCursor + + async with database() as db: + root = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "block")) + cursor = await db.scalar(select(SyncCursor)) + assert ( + str(root.membership_attempt_id) + == cursor.cursor_data["canonical_cycle"]["root_writer_attempt_id"] + ) + assert root.revision == 9 and root.phase == "complete" + child = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "leaf")) + assert child.parent_record_id is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_full_parent_admission.py b/backend/airweave/domains/entities/canonical/tests/test_full_parent_admission.py new file mode 100644 index 0000000000..47ba5f6e24 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_full_parent_admission.py @@ -0,0 +1,71 @@ +"""Full child acquisition must admit the exact parent inventory snapshot.""" + +import pytest + +from airweave.domains.entities.canonical.scan_models import BeginScan +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.entities.canonical.tests.test_cycles import ( + CHILD, + CONFIG, + cycle, + finish, + page, + record, + scan, +) + + +async def test_full_parent_inventory_epoch_is_required_at_begin_resume_and_restart( + database, source +): + service, fence = source + active = await cycle(database, service, fence) + root = await scan(database, service, fence, active) + parent = record().model_copy( + update={"payload": {"files": ["F1"]}, "descendant_visibility_fields": ("files",)} + ) + root = await page(database, service, fence, root, parent, final=True) + await finish(database, service, fence, root) + async with database() as db: + work = await service.next_scope_work(db, fence, active.version.cycle_id) + request = BeginScan( + fence=fence, + scope=CHILD, + cycle_id=active.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected_parent_epoch=work.parent_visibility_epoch, + ) + # A source may retain this parent while another admitted operation updates it. + await capture( + database, service, fence, parent.model_copy(update={"payload": {"files": ["F2"]}}) + ) + async with database() as db: + with pytest.raises(ScanConflict, match="owner changed"): + await service.begin_scan(db, request) + async with database() as db: + current = await service.next_scope_work(db, fence, active.version.cycle_id) + request = request.model_copy( + update={"expected_parent_epoch": current.parent_visibility_epoch} + ) + admitted = await service.begin_scan(db, request) + # Full acquisition depends on attachment inventory, not unrelated text revision. + await capture( + database, + service, + fence, + parent.model_copy(update={"payload": {"files": ["F2"], "text": "edited"}}), + ) + async with database() as db: + resumed = await service.begin_scan(db, request) + assert resumed.version == admitted.version + await capture( + database, service, fence, parent.model_copy(update={"payload": {"files": ["F3"]}}) + ) + for restart in (False, True): + async with database() as db: + with pytest.raises(ScanConflict, match="owner changed"): + await service.begin_scan( + db, + request.model_copy(update={"expected": admitted.version, "restart": restart}), + ) diff --git a/backend/airweave/domains/entities/canonical/tests/test_github_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_github_recovery.py new file mode 100644 index 0000000000..f10ed9813e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_github_recovery.py @@ -0,0 +1,256 @@ +"""Real SQL lifecycle with native synthetic GitHub HTTP and actual hierarchy adapter.""" + +import hashlib +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qs, urlsplit +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import BlobReference +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor +from airweave.platform.configs.config import GitHubConfig +from airweave.platform.sources.github import GitHubSource + +REPO = {"id": 100, "owner": {"id": 200}, "full_name": "team/repo", "default_branch": "main"} +ISSUE = { + "id": 300, + "number": 7, + "repository_url": "https://api.github.com/repos/team/repo", + "title": "Synthetic issue", +} + + +class NativeGitHub: + """Fixture native pages, retaining actual endpoint/query semantics.""" + + def __init__(self): + self.code = False + self.blob_reads = [] + self.code_bytes = b"synthetic code" + self.blob_sha = hashlib.sha1(b"blob 14\0" + self.code_bytes).hexdigest() + self.comment_pages = [] + self.unavailable = False + + async def get(self, url, **kwargs): + parsed = urlsplit(url) + headers = {} + status = 200 + if self.unavailable: + payload, status = {}, 404 + elif parsed.path == "/repos/team/repo": + payload = REPO + elif parsed.path == "/repos/team/repo/issues": + payload = [ISSUE] + elif parsed.path == "/repos/team/repo/issues/7": + payload = ISSUE + elif parsed.path == "/repos/team/repo/issues/7/comments": + page = int(parse_qs(parsed.query)["page"][0]) + self.comment_pages.append(page) + payload = [{"id": 500 + page, "body": f"Synthetic page {page}"}] + if page == 1: + headers["Link"] = ( + "; rel="next"' + ) + else: + return self.code_response(url) + return httpx.Response( + status, json=payload, headers=headers, request=httpx.Request("GET", url) + ) + + def code_response(self, url): + parsed = urlsplit(url) + if parsed.path.endswith("/branches/main"): + payload = {"name": "main", "commit": {"sha": "1" * 40}} + elif "/git/commits/" in parsed.path: + payload = {"sha": "1" * 40, "tree": {"sha": "2" * 40}} + elif "/git/trees/" in parsed.path: + payload = { + "sha": "2" * 40, + "truncated": False, + "tree": [ + { + "path": name, + "mode": "100644", + "type": "blob", + "sha": self.blob_sha, + "size": len(self.code_bytes), + } + for name in ("first.py", "second.py") + ], + } + elif "/git/blobs/" in parsed.path: + self.blob_reads.append(parsed.path) + return httpx.Response(200, content=self.code_bytes, request=httpx.Request("GET", url)) + else: + raise AssertionError(f"Unexpected synthetic route: {parsed.path}") + return httpx.Response(200, json=payload, request=httpx.Request("GET", url)) + + +async def runner(database, source, native, *, attempt=1, service=None): + default, fence = source + ctx, _, runtime, bus = components(database, source) + client = AsyncMock() + client.get.side_effect = native.get + + @asynccontextmanager + async def stream(method, url, **kwargs): + original = await native.get(url, **kwargs) + streamed = httpx.Response( + original.status_code, + headers=original.headers, + stream=httpx.ByteStream(original.content), + ) + try: + yield streamed + finally: + await streamed.aclose() + + client.stream = MagicMock(side_effect=stream) + files = MagicMock() + + async def store_blob(content, *, media_type=None): + digest = hashlib.sha256(content).hexdigest() + return BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type=media_type, + ) + + files.store_canonical_blob = AsyncMock(side_effect=store_blob) + connector = await GitHubSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GitHubConfig.model_validate( + { + "expected_user_id": 42, + "repositories": [{"repository_id": 100, "owner_id": 200, "full_name": "team/repo"}], + "include_code": native.code, + } + ), + ) + pipeline = CanonicalCapturePipeline( + service or default, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=files, + ) + runtime.source, runtime.canonical_capture = connector, pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + return instance + + +async def test_nested_comment_lost_ack_resumes_without_skipping_or_repeating_committed_page( + database, source +): + class LostAck(CanonicalCaptureService): + async def commit_scan_page(self, db, request): + result = await super().commit_scan_page(db, request) + if request.scope.record_type == "comment": + raise ConnectionError("synthetic lost acknowledgment") + return result + + native = NativeGitHub() + first = await runner(database, source, native, service=LostAck(source[0].store)) + with pytest.raises(ConnectionError, match="acknowledgment"): + await run(first) + async with database() as db: + comment_scan = await db.scalar( + select(CaptureScan).where(CaptureScan.record_type == "comment") + ) + assert comment_scan.continuation["page"] == 2 + prior_sweep = comment_scan.sweep_id + assert (await db.scalar(select(SyncCursor))).cursor_data["canonical_cycle"][ + "phase" + ] == "active" + second = await runner(database, source, native, attempt=2) + await run(second) + assert native.comment_pages == [1, 2] + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + assert {row.native_id for row in rows} == {"100", "300", "501", "502"} + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "comment")) + assert scan.sweep_id == prior_sweep and scan.phase == "complete" + assert (await db.scalar(select(SyncCursor))).cursor_data["canonical_cycle"][ + "phase" + ] == "complete" + + +async def test_repository_loss_on_retry_hides_all_nested_originals(database, source): + class LostAck(CanonicalCaptureService): + async def commit_scan_page(self, db, request): + result = await super().commit_scan_page(db, request) + if request.scope.record_type == "comment": + raise ConnectionError("synthetic interruption") + return result + + native = NativeGitHub() + first = await runner(database, source, native, service=LostAck(source[0].store)) + with pytest.raises(ConnectionError): + await run(first) + native.unavailable = True + await run(await runner(database, source, native, attempt=2)) + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + assert rows and all(row.deleted_at is not None for row in rows) + assert all(row.removal_reason == "scope_removed" for row in rows) + for row in rows: + current = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, row.id + ) + assert ( + current.content_access == "unavailable" + and current.payload == {} + and not current.blobs + ) + assert (await db.scalar(select(SyncCursor))).cursor_data["canonical_cycle"][ + "phase" + ] == "complete" + + +async def test_code_scope_resumes_after_committed_blob_without_repeating_first_file( + database, source +): + class LostAck(CanonicalCaptureService): + async def commit_scan_page(self, db, request): + result = await super().commit_scan_page(db, request) + if request.scope.record_type == "file": + raise ConnectionError("synthetic file commit acknowledgment loss") + return result + + native = NativeGitHub() + native.code = True + with pytest.raises(ConnectionError, match="acknowledgment"): + await run(await runner(database, source, native, service=LostAck(source[0].store))) + async with database() as db: + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "file")) + assert scan.continuation["stack"][0]["offset"] == 1 + sweep = scan.sweep_id + await run(await runner(database, source, native, attempt=2)) + assert len(native.blob_reads) == 2 + async with database() as db: + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "file")) + assert scan.sweep_id == sweep and scan.phase == "complete" + files = ( + await db.scalars(select(Entity).where(Entity.entity_definition_short_name == "file")) + ).all() + assert {item.native_id for item in files} == {"first.py", "second.py"} diff --git a/backend/airweave/domains/entities/canonical/tests/test_gmail_observation.py b/backend/airweave/domains/entities/canonical/tests/test_gmail_observation.py new file mode 100644 index 0000000000..3d3382b28d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_gmail_observation.py @@ -0,0 +1,80 @@ +"""Raw Gmail locator observations retain provenance even when blob bytes match.""" + +from copy import deepcopy +from datetime import datetime, timezone +from hashlib import sha256 +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.requests import ( + BlobReference, + CaptureRecord, + RecordIdentity, +) +from airweave.domains.entities.canonical.store import capture_fingerprint +from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + publish_prepared, +) + +pytestmark = pytest.mark.integration + + +async def test_locator_rotation_preserves_originals_and_requires_current_publication( + database, source +): + service, fence = source + await bind_projection(database, fence, "gmail") + digest = sha256(b"identical message body").hexdigest() + payload = { + "id": "synthetic-message", + "threadId": "synthetic-thread", + "internalDate": "1700000000000", + "labelIds": ["INBOX"], + "payload": { + "mimeType": "text/plain", + "partId": "0", + "body": {"size": 22, "attachmentId": "locator-A"}, + }, + } + original = CaptureRecord( + identity=RecordIdentity(record_type="message", native_id=payload["id"]), + payload=payload, + observed_at=datetime.now(timezone.utc), + blobs=( + BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=22, + media_type="text/plain", + source_path="/payload/body", + ), + ), + ) + rotated_payload = deepcopy(payload) + rotated_payload["payload"]["body"]["attachmentId"] = "locator-B" + rotated = original.model_copy(update={"payload": rotated_payload}) + assert capture_fingerprint(original) != capture_fingerprint(rotated) + first = await capture(database, service, fence, original) + projections = CanonicalProjectionStore() + async with database() as db: + work = (await projections.pending(db, fence.organization_id, fence.sync_id))[0] + assert await publish_prepared(projections, db, work, uuid4(), 1) + async with database() as db: + assert not await projections.pending(db, fence.organization_id, fence.sync_id) + second = await capture(database, service, fence, rotated) + async with database() as db: + pending = await projections.pending(db, fence.organization_id, fence.sync_id) + history = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert first.changes[0].record.revision == 1 + assert second.changes[0].record.revision == 2 + assert len(pending) == 1 and pending[0].record.revision == 2 + assert [c.record.payload["payload"]["body"]["attachmentId"] for c in history.changes] == [ + "locator-A", + "locator-B", + ] + replay = await capture(database, service, fence, rotated) + assert replay.unchanged == 1 and not replay.changes diff --git a/backend/airweave/domains/entities/canonical/tests/test_gmail_projection.py b/backend/airweave/domains/entities/canonical/tests/test_gmail_projection.py new file mode 100644 index 0000000000..6ba89e4a0a --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_gmail_projection.py @@ -0,0 +1,345 @@ +"""Replay source MIME content without provider requests or lossy snippet fallbacks.""" + +import base64 +import hashlib +from datetime import datetime, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.blob_materializer import read_blob +from airweave.domains.entities.canonical.gmail_projection import map_gmail +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity + + +def part(content=b"hello", mime="text/plain", **fields): + return { + "mimeType": mime, + "body": {"data": base64.urlsafe_b64encode(content).decode(), "size": len(content)}, + **fields, + } + + +def record(payload, **changes): + values = { + "id": uuid4(), + "sync_id": uuid4(), + "identity": RecordIdentity(record_type="message", native_id="m1"), + "revision": 1, + "payload": { + "id": "m1", + "threadId": "t1", + "internalDate": "1000", + "snippet": "must not substitute this snippet", + "payload": payload, + }, + "payload_schema_version": 1, + "capture_hash": "capture", + "content_hash": None, + "completeness": "complete", + "observed_at": datetime.now(timezone.utc), + "source_created_at": None, + "source_updated_at": None, + "deleted_at": None, + "removal_reason": None, + "blobs": (), + "indexed_revision": None, + "indexed_pipeline_version": None, + } + values.update(changes) + return SourceRecord(**values) + + +def blob(source, data, source_path="/payload/body"): + digest = hashlib.sha256(data).hexdigest() + return BlobReference( + key=f"canonical/{source.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(data), + source_path=source_path, + ) + + +@pytest.mark.asyncio +async def test_plain_charset_body_and_quoted_address_are_preserved(tmp_path): + source = record( + part( + "café ".encode("latin-1"), + headers=[ + {"name": "Content-Type", "value": "text/plain; charset=iso-8859-1"}, + {"name": "To", "value": '"Doe, Jane" , bob@example.com'}, + {"name": "Subject", "value": "Subject"}, + ], + ) + ) + before = source.model_dump() + entities = await map_gmail(source, AsyncMock(), tmp_path) + entities = entities.entities + assert len(entities) == 1 + assert entities[0].to == ['"Doe, Jane" ', "bob@example.com"] + assert "café <hello>" in next(tmp_path.glob("*.html")).read_text() + assert source.model_dump() == before + + +@pytest.mark.asyncio +async def test_mislabeled_utf8_body_is_recovered_without_changing_original(tmp_path): + text = "

नमस्ते — café

" + source = record( + part( + text.encode(), + "text/html", + headers=[{"name": "Content-Type", "value": "text/html; charset=gb2312"}], + ) + ) + before = source.model_dump() + result = await map_gmail(source, AsyncMock(), tmp_path) + assert next(tmp_path.glob("*.html")).read_text() == text + assert result.parts[0].part.charset_recoveries[0].source_path == "/payload" + assert result.parts[0].part.charset_recoveries[0].from_charset == "gb2312" + assert source.model_dump() == before + + +@pytest.mark.asyncio +async def test_alternative_prefers_full_html_not_plain_duplicate_or_snippet(tmp_path): + source = record( + { + "mimeType": "multipart/alternative", + "parts": [ + part(b"unselected plain"), + part(b"

Full HTML body

", "text/html"), + ], + } + ) + entities = await map_gmail(source, AsyncMock(), tmp_path) + entities = entities.entities + text = next(tmp_path.glob("*.html")).read_text() + assert text == "

Full HTML body

" + assert entities[0].local_path is not None + + +@pytest.mark.parametrize("html_body", [b"", b" \n\t "]) +@pytest.mark.asyncio +async def test_alternative_falls_back_to_plain_when_html_is_empty(tmp_path, html_body): + source = record( + { + "mimeType": "multipart/alternative", + "parts": [part(b"usable plain body", "text/plain"), part(html_body, "text/html")], + } + ) + + await map_gmail(source, AsyncMock(), tmp_path) + + assert next(tmp_path.glob("*.html")).read_text() == "
usable plain body
" + + +@pytest.mark.asyncio +async def test_related_empty_html_alternative_falls_back_without_materializing_siblings(tmp_path): + source = record( + { + "mimeType": "multipart/alternative", + "parts": [ + part(b"usable plain body", "text/plain"), + { + "mimeType": "multipart/related", + "parts": [ + part(b" \n ", "text/html"), + part(b"image bytes", "image/png", filename="image.png"), + ], + }, + ], + } + ) + + entities = await map_gmail(source, AsyncMock(), tmp_path) + + assert len(entities.entities) == 1 + assert next(tmp_path.glob("*.html")).read_text() == "
usable plain body
" + + +@pytest.mark.asyncio +async def test_unreadable_html_alternative_does_not_fall_back_to_plain(tmp_path): + source = record( + { + "mimeType": "multipart/alternative", + "parts": [ + part(b"usable plain body", "text/plain"), + {"mimeType": "text/html", "body": {"attachmentId": "missing", "size": 0}}, + ], + } + ) + + with pytest.raises(ValueError, match="exactly one"): + await map_gmail(source, AsyncMock(), tmp_path) + + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.asyncio +async def test_last_readable_html_skips_earlier_missing_blob_and_reads_selected_once( + tmp_path, nested +): + payload = { + "mimeType": "multipart/alternative", + "parts": [ + {"mimeType": "text/html", "body": {"attachmentId": "missing", "size": 0}}, + {"mimeType": "text/html", "body": {"attachmentId": "selected", "size": 0}}, + ], + } + body_path = "/payload/parts/1/body" + if nested: + payload = { + "mimeType": "multipart/alternative", + "parts": [ + part(b"plain", "text/plain"), + {"mimeType": "multipart/related", "parts": [payload]}, + ], + } + body_path = "/payload/parts/1/parts/0/parts/1/body" + source = record(payload) + content = b"

Selected external HTML

" + source = source.model_copy(update={"blobs": (blob(source, content, body_path),)}) + storage = AsyncMock() + storage.read_file.return_value = content + + await map_gmail(source, storage, tmp_path) + + assert next(tmp_path.glob("*.html")).read_text() == content.decode() + storage.read_file.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_external_blob_and_duplicate_filename_attachments_get_distinct_identity(tmp_path): + source = record( + { + "mimeType": "multipart/mixed", + "parts": [ + {"mimeType": "text/html", "body": {"attachmentId": "body", "size": 10}}, + part(b"one", "text/plain", filename="../../same.txt", partId="1"), + part(b"two", "text/plain", filename="../../same.txt", partId="2"), + ], + } + ) + data = b"body" + source = source.model_copy(update={"blobs": (blob(source, data, "/payload/parts/0/body"),)}) + # Canonical SHA and actual size are authoritative even when native size differs. + assert source.payload["payload"]["parts"][0]["body"]["size"] != len(data) + storage = AsyncMock() + storage.read_file.return_value = data + entities = await map_gmail(source, storage, tmp_path) + entities = entities.entities + assert len(entities) == 3 + assert entities[1].attachment_key != entities[2].attachment_key + from pathlib import Path + + assert all(Path(e.local_path).parent == tmp_path for e in entities) + assert {Path(e.local_path).read_bytes() for e in entities[1:]} == {b"one", b"two"} + + +@pytest.mark.asyncio +async def test_missing_blob_ref_fails_instead_of_indexing_snippet(tmp_path): + source = record({"mimeType": "text/html", "body": {"attachmentId": "body", "size": 3}}) + with pytest.raises(ValueError, match="exactly one"): + await map_gmail(source, AsyncMock(), tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.asyncio +async def test_cross_source_blob_is_rejected_before_storage_access(): + source = record(part()) + reference = blob(source, b"abc").model_copy(update={"key": "canonical/other/blobs/sha256/hash"}) + storage = AsyncMock() + with pytest.raises(ValueError, match="source scope"): + await read_blob(source, reference, storage) + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_corrupt_blob_bytes_fail_before_projection(tmp_path): + source = record({"mimeType": "text/plain", "body": {"attachmentId": "body", "size": 3}}) + source = source.model_copy(update={"blobs": (blob(source, b"abc"),)}) + storage = AsyncMock() + storage.read_file.return_value = b"bad" + with pytest.raises(ValueError, match="digest"): + await map_gmail(source, storage, tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.asyncio +async def test_missing_attachment_keeps_available_body_searchable_and_original_partial(tmp_path): + source = record( + { + "mimeType": "multipart/mixed", + "parts": [ + part(b"Available message text"), + { + "mimeType": "application/pdf", + "filename": "uncaptured.pdf", + "body": {"attachmentId": "large", "size": 500000000}, + }, + ], + }, + completeness="partial", + ) + before = source.model_dump() + storage = AsyncMock() + entities = await map_gmail(source, storage, tmp_path) + entities = entities.entities + assert len(entities) == 1 + assert "Available message text" in next(tmp_path.glob("*.html")).read_text() + assert source.model_dump() == before + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_partial_capture_still_requires_actual_message_body(tmp_path): + source = record( + {"mimeType": "text/plain", "body": {"attachmentId": "body", "size": 3}}, + completeness="partial", + ) + with pytest.raises(ValueError, match="exactly one"): + await map_gmail(source, AsyncMock(), tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.asyncio +async def test_related_html_branch_does_not_get_replaced_by_plain_alternative(tmp_path): + source = record( + { + "mimeType": "multipart/alternative", + "parts": [ + part(b"plain duplicate"), + { + "mimeType": "multipart/related", + "parts": [ + part(b"

Complete rich content

", "text/html"), + part(b"image bytes", "image/png", filename="image.png"), + ], + }, + ], + } + ) + entities = await map_gmail(source, AsyncMock(), tmp_path) + entities = entities.entities + assert len(entities) == 2 + from pathlib import Path + + assert Path(entities[0].local_path).read_text().strip() == "

Complete rich content

" + assert entities[1].file_type == "png" + + +@pytest.mark.asyncio +async def test_inline_native_size_discrepancy_preserves_data_and_metadata(tmp_path): + source = record(part(b"

Retained full body

", "text/html")) + source.payload["payload"]["body"]["size"] -= 1 + before = source.model_dump() + mapped = await map_gmail(source, AsyncMock(), tmp_path) + from pathlib import Path + + assert Path(mapped.entities[0].local_path).read_bytes() == b"

Retained full body

" + assert source.model_dump() == before + source.payload["payload"]["body"]["data"] = "invalid!!" + with pytest.raises(ValueError): + await map_gmail(source, AsyncMock(), tmp_path) diff --git a/backend/airweave/domains/entities/canonical/tests/test_gmail_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_gmail_recovery.py new file mode 100644 index 0000000000..899fe92df4 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_gmail_recovery.py @@ -0,0 +1,340 @@ +"""Actual Gmail transport/page source, lifecycle and PostgreSQL; native HTTP is synthetic.""" + +from contextlib import aclosing +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.entities.canonical.tests.test_changes_cycles import record +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob +from airweave.platform.configs.config import GmailConfig +from airweave.platform.sources.gmail import GmailSource +from airweave.platform.sources.tests.test_gmail_capture import BASE, message + + +class NativeHTTP: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + async def handle(self, request): + if request.url.params.get("fields") == "emailAddress": + return httpx.Response(200, json={"emailAddress": "synthetic@example.test"}) + self.calls.append((str(request.url).split("?")[0], dict(request.url.params))) + expected, response = self.responses.pop(0) + assert str(request.url).split("?")[0] == BASE + expected + if isinstance(response, Exception): + raise response + status, payload = response if isinstance(response, tuple) else (200, response) + return httpx.Response(status, json=payload) + + +async def setup(database, source, native, *, attempt=1, query=None): + service, fence = source + client = httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) + connector = await GmailSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GmailConfig( + expected_mailbox="synthetic@example.test", + gmail_query=query, + included_labels=[], + excluded_labels=[], + excluded_categories=[], + ), + ) + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + await pipeline.start(ctx) + return pipeline, ctx, runtime, client + + +async def no_limits(): + pass + + +async def run(pipeline, ctx, runtime): + await pipeline.run_scans(ctx, runtime, no_limits) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + await pipeline.save_checkpoint(ctx, runtime) + + +async def test_full_resume_interleaved_history_known_omission_and_next_delta(database, source): + service, fence = source + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(record("known"),))) + native = NativeHTTP( + [ + ("/profile", {"historyId": "100"}), + ("/messages", {"messages": [{"id": "a"}], "nextPageToken": "second"}), + ("/messages/a", message("a")), + ( + "/history", + {"historyId": "110", "history": [{"messagesAdded": [{"message": {"id": "c"}}]}]}, + ), + ("/messages/c", message("c")), + ("/messages", ConnectionError("synthetic interruption")), + ("/messages", {"messages": [{"id": "b"}]}), + ("/messages/b", message("b")), + ( + "/history", + {"historyId": "120", "history": [{"messagesDeleted": [{"message": {"id": "a"}}]}]}, + ), + ("/messages/a", (404, {"error": {"message": "gone"}})), + ("/messages/known", message("known")), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + with pytest.raises(ConnectionError, match="interruption"): + await run(pipeline, ctx, runtime) + async with database() as db: + cursor = await db.scalar(select(SyncCursor)) + assert "canonical_checkpoint" not in cursor.cursor_data + assert cursor.cursor_data["canonical_cycle"]["promoted_checkpoint"] is None + pipeline, ctx, runtime, client = await setup(database, source, native, attempt=2) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert len([url for url, _ in native.calls if url.endswith("/profile")]) == 1 + assert [ + params["startHistoryId"] for url, params in native.calls if url.endswith("/history") + ] == ["100", "110"] + assert native.calls[6][1]["pageToken"] == "second" + assert not native.responses + async with database() as db: + full = await service.read_cycle(db, pipeline._writer()) + assert full.promoted_checkpoint.checkpoint.value == {"history_id": "120"} + assert { + r.native_id for r in (await db.scalars(select(Entity))).all() if r.deleted_at is None + } == {"b", "c", "known"} + old = await db.get(SyncJob, fence.job_id) + old.status = "completed" + job_id = uuid4() + db.add( + SyncJob( + id=job_id, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + next_fence = await service.activate_writer( + db, fence.organization_id, fence.sync_id, job_id, attempt_id=uuid4(), attempt_number=1 + ) + delta = NativeHTTP( + [ + ( + "/history", + {"historyId": "130", "history": [{"labelsAdded": [{"message": {"id": "b"}}]}]}, + ), + ("/messages/b", message("b", labelIds=["TRASH"])), + ] + ) + pipeline, ctx, runtime, client = await setup(database, (service, next_fence), delta) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert delta.calls[0][1]["startHistoryId"] == "120" + assert not delta.responses + async with database() as db: + changed = await service.read_cycle(db, pipeline._writer()) + assert changed.mode == "changes" and changed.last_full_capture == full.last_full_capture + assert changed.promoted_checkpoint.checkpoint.value == {"history_id": "130"} + assert ( + await db.scalar(select(Entity).where(Entity.native_id == "known")) + ).deleted_at is None + assert (await db.scalar(select(Entity).where(Entity.native_id == "b"))).source_payload[ + "labelIds" + ] == ["TRASH"] + + +async def test_filtered_mutating_view_omission_is_not_provider_deletion(database, source): + service, fence = source + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(record("no-longer-listed"),))) + native = NativeHTTP( + [("/messages", {"messages": [{"id": "a"}]}), ("/messages/a", message("a", labelIds=[]))] + ) + pipeline, ctx, runtime, client = await setup(database, source, native, query="in:inbox") + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert native.calls[0][1]["q"] == "in:inbox" + async with database() as db: + cycle = await service.read_cycle(db, pipeline._writer()) + removed = await db.scalar(select(Entity).where(Entity.native_id == "no-longer-listed")) + assert cycle.promoted_checkpoint is None + assert removed.removal_reason == "absent" and removed.deleted_at is not None + + +async def test_history_expiry_restarts_full_without_using_partial_sightings(database, source): + native = NativeHTTP( + [ + ("/profile", {"historyId": "old"}), + ("/messages", {"messages": [{"id": "partial"}]}), + ("/messages/partial", message("partial")), + ("/history", (404, {"error": {"message": "expired"}})), + ("/profile", {"historyId": "fresh"}), + ("/messages", {}), + ("/history", {"historyId": "terminal"}), + ("/messages/partial", (404, {"error": {"message": "gone"}})), + ] + ) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + await run(pipeline, ctx, runtime) + async with database() as db: + cycle = await source[0].read_cycle(db, pipeline._writer()) + partial = await db.scalar(select(Entity).where(Entity.native_id == "partial")) + assert cycle.starting_checkpoint.value == {"history_id": "fresh"} + assert cycle.promoted_checkpoint.checkpoint.value == {"history_id": "terminal"} + assert partial.deleted_at is not None and partial.removal_reason == "provider_deleted" + + +async def test_history_offset_resume_and_lost_final_ack_do_not_rehydrate_committed_messages( + database, source +): + raw = { + "historyId": "20", + "history": [{"id": "entry", "messages": [{"id": str(i)} for i in range(51)]}], + } + responses = [("/profile", {"historyId": "10"}), ("/messages", {}), ("/history", raw)] + responses.extend((f"/messages/{i}", message(str(i))) for i in range(50)) + responses.extend( + [ + ("/history", ConnectionError("synthetic partial history stop")), + ("/history", raw), + ("/messages/50", message("50")), + ] + ) + native = NativeHTTP(responses) + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + with pytest.raises(ConnectionError, match="partial history"): + await run(pipeline, ctx, runtime) + pipeline, ctx, runtime, client = await setup(database, source, native, attempt=2) + async with aclosing(client): + await pipeline.run_scans(ctx, runtime, no_limits) + # The process disappears after its durable final page, before final checkpoint publication. + assert not native.responses + calls = len(native.calls) + pipeline, ctx, runtime, client = await setup(database, source, native, attempt=3) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert len(native.calls) == calls + assert len([url for url, _ in native.calls if "/messages/" in url]) == 51 + async with database() as db: + state = await source[0].read_cycle(db, pipeline._writer()) + assert state.promoted_checkpoint.checkpoint.value == {"history_id": "20"} + assert state.phase == "complete" + + +async def test_new_job_resumes_active_cycle_after_exhausted_server_error( + database, source, monkeypatch +): + from tenacity import wait_none + + from airweave.domains.sources.exceptions import SourceServerError + from airweave.domains.syncs.jobs.repository import SyncJobRepository + + monkeypatch.setattr( + GmailSource, + "_get_capture_json", + GmailSource._get_capture_json.retry_with(wait=wait_none()), + ) + native = NativeHTTP( + [ + ("/profile", {"historyId": "100"}), + ("/messages", {"messages": [{"id": "a"}], "nextPageToken": "second"}), + ("/messages/a", message("a")), + ("/history", {"historyId": "110"}), + *[("/messages", (503, {"error": {"message": "synthetic transient"}}))] * 5, + ("/messages", {"messages": [{"id": "b"}]}), + ("/messages/b", message("b")), + ("/history", {"historyId": "120"}), + ] + ) + service, fence = source + pipeline, ctx, runtime, client = await setup(database, source, native) + async with aclosing(client): + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + incomplete = await service.read_cycle(db, pipeline._writer()) + assert incomplete.phase == "active" + assert incomplete.promoted_checkpoint is None + # Model the production orchestrator's terminal failure, then the next + # eligible scheduled job. This is not a Temporal workflow execution. + old = await db.get(SyncJob, fence.job_id) + old.status = "failed" + await db.commit() + assert not await SyncJobRepository().get_active_for_sync(db, fence.sync_id, ctx) + job_id = uuid4() + db.add( + SyncJob( + id=job_id, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + next_fence = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + job_id, + attempt_id=uuid4(), + attempt_number=1, + ) + pipeline, ctx, runtime, client = await setup(database, (service, next_fence), native) + async with aclosing(client): + await run(pipeline, ctx, runtime) + assert not native.responses + assert sum(url.endswith("/messages/a") for url, _ in native.calls) == 1 + assert all(params["pageToken"] == "second" for url, params in native.calls[4:10]) + async with database() as db: + completed = await service.read_cycle(db, pipeline._writer()) + assert completed.version.cycle_id == incomplete.version.cycle_id + assert completed.phase == "complete" + assert completed.promoted_checkpoint.checkpoint.value == {"history_id": "120"} + assert {row.native_id for row in (await db.scalars(select(Entity))).all()} == {"a", "b"} + + +async def test_wrong_native_mailbox_never_starts_capture(database, source): + native = NativeHTTP([]) + async with database() as db: + before = (await db.execute(select(SyncCursor))).scalars().all() + before = [(item.id, item.cursor_data) for item in before] + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) as client: + with pytest.raises(ValueError, match="does not match"): + await GmailSource.create( + auth=StaticTokenProvider("same-broker-account"), + logger=MagicMock(), + http_client=client, + config=GmailConfig(expected_mailbox="different@example.test"), + ) + assert native.calls == [] + async with database() as db: + assert (await db.execute(select(Entity))).scalars().all() == [] + after = (await db.execute(select(SyncCursor))).scalars().all() + assert [(item.id, item.cursor_data) for item in after] == before diff --git a/backend/airweave/domains/entities/canonical/tests/test_gmail_resume_probe.py b/backend/airweave/domains/entities/canonical/tests/test_gmail_resume_probe.py new file mode 100644 index 0000000000..57c88dd83f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_gmail_resume_probe.py @@ -0,0 +1,261 @@ +"""Gmail harness proof uses fresh processes and isolated PostgreSQL, no live provider.""" + +import json + +from sqlalchemy import text + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + +SCRIPT = r""" +import asyncio, os, sys +from pathlib import Path +from uuid import uuid4 +from unittest.mock import MagicMock +sys.path.insert(0, "tests/live") +import conftest +import httpx +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from airweave.domains.entities.canonical.requests import WriterFence +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run +from airweave.domains.entities.canonical.tests.test_gmail_recovery import NativeHTTP +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.platform.configs.config import GmailConfig +from airweave.platform.sources.gmail import GmailSource +from airweave.platform.sources.tests.test_gmail_capture import message +from gmail_resume import GmailResumeProbe +from provider_lifecycle import bounded_page_source + +async def main(): + engine = create_async_engine(os.environ["CANONICAL_TEST_DATABASE_URL"], + connect_args={"server_settings": {"search_path": os.environ["TEST_SCHEMA"]}}) + sessions = async_sessionmaker(engine, expire_on_commit=False) + fence = WriterFence.model_validate_json(os.environ["TEST_FENCE"]) + service = CanonicalCaptureService(CanonicalRecordStore()) + mode = os.environ["TEST_MODE"] + responses = [('/profile', {'historyId': 'initial'}), + ('/messages', {'messages': [{'id': 'a'}], 'nextPageToken': 'next'}), + ('/messages/a', message('a'))] if mode == 'interrupt' else [ + ('/history', {'historyId': 'later', 'history': [{'messages': [{'id': 'b'}]}]}), + ('/messages/b', message('b'))] + native = NativeHTTP(responses) + counters = {'provider_requests': 0, 'records_observed': 0, 'capture_profile_requests': 0, + 'blob_bytes_written': 0} + async def handle(request): + counters['provider_requests'] += 1 + if request.url.params.get('fields') == 'emailAddress': + return await native.handle(request) + if request.url.path.endswith('/profile'): + counters['capture_profile_requests'] += 1 + return await native.handle(request) + client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + connector = await GmailSource.create(auth=StaticTokenProvider('synthetic'), + logger=MagicMock(), http_client=client, config=GmailConfig( + expected_mailbox="synthetic@example.test", + included_labels=[], excluded_labels=[], excluded_categories=[])) + ctx, _, runtime, bus = components(sessions, (service, fence)) + attempt = CaptureAttempt(id=fence.attempt_id if mode == 'interrupt' else uuid4(), + number=1 if mode == 'interrupt' else 2) + probe = GmailResumeProbe(sessions, fence.organization_id, fence.sync_id, fence.job_id, + attempt.id, Path(os.environ['TEST_TARGET']), mode, counters) + wrapper = bounded_page_source(connector, counters, 250, probe) + pipeline = CanonicalCapturePipeline(service, sessions, bus, connector.canonical_record_types, + attempt, connector.canonical_container_parents, page_source=wrapper, files=MagicMock()) + runtime.source, runtime.canonical_capture = connector, pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + await run(instance) +asyncio.run(main()) +""" + + +async def test_gmail_process_loss_resumes_saved_plan_without_profile(database, source, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + env = { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + } + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "interrupt"}) + assert code == 75, error + first = json.loads(output.splitlines()[-1]) + assert first["intentional_interruption"] and first["records_observed"] == 1 + assert first["capture_profile_requests"] == 1 + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "resume"}) + assert code == 76, error + second = json.loads(output.splitlines()[-1]) + assert second["gmail_recovery_verified"] and second["resumed_saved_page"] + assert second["capture_profile_requests"] == 0 and second["records_observed"] == 1 + assert not second["full_scope_completed"] and not second["checkpoint_saved"] + assert (tmp_path / "target.json").stat().st_mode & 0o077 == 0 + + +async def test_gmail_harness_budgets_capabilities_and_early_completion(tmp_path): + script = r""" +import asyncio, json, os, sys +from types import SimpleNamespace +sys.path.insert(0, 'tests/live') +import conftest +import httpx +import provider_lifecycle as lifecycle +from airweave.domains.entities.canonical.page_source import ( + CheckpointedPageSource, KnownObjectSource) + +class Plain: + canonical_record_types = ('message',) + canonical_container_parents = {} + capture_cycle_configuration = None + +class Known(Plain): + async def refresh_known(self, record, *, files): + return record + +class Planned(Known): + async def prepare_cycle(self, previous): + return previous + def initial_continuation(self, cycle): + return cycle + +async def main(): + counters = {'records_observed': 0} + for source, known, planned in [(Plain(), False, False), (Known(), True, False), + (Planned(), True, True)]: + wrapper = lifecycle.bounded_page_source(source, counters, 1) + assert isinstance(wrapper, CheckpointedPageSource) == planned + assert isinstance(wrapper, KnownObjectSource) == known + assert await wrapper.prepare_cycle('plan') == 'plan' + assert wrapper.initial_continuation('saved') == 'saved' + assert await wrapper.refresh_known('record', files=None) == 'record' + try: + await wrapper.refresh_known('record', files=None) + except lifecycle.BudgetExceeded: + pass + else: + raise AssertionError('Exact reads bypassed record budget') + counters = {'identity_profile_requests': 0, 'capture_profile_requests': 0, + 'resumed_changes_requests': 0} + request = httpx.Request('GET', 'https://gmail.googleapis.com/gmail/v1/users/me/profile') + lifecycle.count_gmail_request('gmail', request, {}, counters, False) + lifecycle.count_gmail_request('gmail', request, {}, counters, True) + assert counters['identity_profile_requests'] == counters['capture_profile_requests'] == 1 + for early in (False, True): + snapshots = [] + async def execute(path, timeout): + snapshots.append(json.loads(path.read_text())) + if early: + return 0, {'failed': False, 'full_scope_completed': True} + second = len(snapshots) == 2 + return (76 if second else 75), {'failed': False, 'intentional_interruption': not second, + 'gmail_recovery_verified': second, 'provider_requests': 7, + 'records_observed': 50, 'blob_bytes_written': 20} + lifecycle.execute_trial = execute + results = await lifecycle.run_trials({'provider': 'gmail', 'gmail_resume': True, + 'root': os.environ['TEST_ROOT'], 'request_limit': 600, 'record_limit': 250, + 'blob_byte_limit': 100, 'timeout': 600}) + if early: + assert len(snapshots) == 1 and results[0]['recovery_not_exercised'] + else: + assert len(snapshots) == 2 + assert [s['request_limit'] for s in snapshots] == [600, 593] + assert [s['record_limit'] for s in snapshots] == [250, 200] + assert [s['blob_byte_limit'] for s in snapshots] == [100, 80] + assert snapshots[0]['job_id'] == snapshots[1]['job_id'] + assert [s['attempt_number'] for s in snapshots] == [1, 2] + print('verified') +asyncio.run(main()) +""" + code, output, error = await subprocess_script(script, {"TEST_ROOT": str(tmp_path)}) + assert code == 0, error + assert output.splitlines()[-1] == "verified" + + +async def test_actual_lifecycle_child_context_reaches_committed_gmail_page( + database, source, tmp_path +): + """Exercise the real child composition, not the separate pipeline-test context.""" + script = r""" +import asyncio, json, os, sys +from contextlib import asynccontextmanager +from unittest.mock import MagicMock +sys.path.insert(0, 'tests/live') +import conftest +import httpx +import provider_lifecycle as lifecycle +from airweave.domains.entities.canonical.tests.test_gmail_recovery import NativeHTTP +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GmailConfig +from airweave.platform.sources.gmail import GmailSource +from airweave.platform.sources.tests.test_gmail_capture import message + +@asynccontextmanager +async def synthetic_source(name, account, expected_email, key, fence, **options): + assert name == 'gmail' and options['gmail_unfiltered'] is True + native = NativeHTTP([ + ('/profile', {'historyId': 'capture-boundary'}), + ('/messages', {'messages': [{'id': 'synthetic-a'}], 'nextPageToken': 'next'}), + ('/messages/synthetic-a', message('synthetic-a')), + ]) + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle), + event_hooks={'request': [options['request_hook']]}) as client: + connector = await GmailSource.create(auth=StaticTokenProvider('synthetic'), + logger=MagicMock(), http_client=client, config=GmailConfig( + expected_mailbox=expected_email, + included_labels=[], excluded_labels=[], excluded_categories=[])) + yield connector, 'provider_email' + +lifecycle.rest_source = synthetic_source +# This offline test uses the disposable database fixture (TCP in CI, socket locally). +# Keep the live harness's private-socket guard unchanged for real account trials. +lifecycle.harness.test_database_url = lambda: os.environ['CANONICAL_TEST_DATABASE_URL'] +# All provider traffic uses the scripted MockTransport; synthetic credentials only. +os.environ['COMPOSIO_API_KEY'] = 'synthetic-test-only' +os.environ['LIVE_GMAIL_ACCOUNT_ID'] = 'synthetic-test-only' +os.environ['LIVE_EXPECTED_EMAIL'] = 'synthetic@example.test' +raise SystemExit(asyncio.run(lifecycle.child(json.loads(os.environ['TEST_MANIFEST'])))) +""" + from sqlalchemy import select + + from airweave.models.capture_scan import CaptureScan + from airweave.models.entity import Entity + from airweave.models.sync_cursor import SyncCursor + + fence = source[1] + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + manifest = { + "provider": "gmail", + "schema": schema, + "root": str(tmp_path), + "organization_id": str(fence.organization_id), + "sync_id": str(fence.sync_id), + "job_id": str(fence.job_id), + "attempt_number": 2, + "gmail_resume": True, + "gmail_unfiltered": True, + "resume_stage": "interrupt", + "record_limit": 250, + "request_limit": 600, + "timeout": 30, + "file_byte_limit": 10 * 1024 * 1024, + "blob_byte_limit": 256 * 1024 * 1024, + } + code, output, error = await subprocess_script(script, {"TEST_MANIFEST": json.dumps(manifest)}) + assert code == 75, (output, error) + result = json.loads(output.splitlines()[-1]) + assert result["intentional_interruption"] and not result["failed"] + assert result["identity_profile_requests"] == result["capture_profile_requests"] == 1 + assert result["provider_requests"] == 4 and result["records_observed"] == 1 + assert not result["checkpoint_saved"] and not result["full_scope_completed"] + async with database() as db: + record = await db.scalar(select(Entity).where(Entity.native_id == "synthetic-a")) + scan = await db.scalar(select(CaptureScan).where(CaptureScan.sync_id == fence.sync_id)) + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == fence.sync_id)) + assert record is not None and record.record_revision == 1 + assert scan.phase == "collecting" and scan.continuation["phase"] == "history" + assert cursor.cursor_data["canonical_cycle"]["phase"] == "active" + assert "canonical_checkpoint" not in cursor.cursor_data diff --git a/backend/airweave/domains/entities/canonical/tests/test_http.py b/backend/airweave/domains/entities/canonical/tests/test_http.py new file mode 100644 index 0000000000..3d767f3f67 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_http.py @@ -0,0 +1,149 @@ +"""HTTP contract verification using the real PostgreSQL-backed query service.""" + +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from airweave.api import deps +from airweave.api.v1.endpoints.records import record_error_response, router +from airweave.db.session import get_db +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import BlobReference, CaptureBatch, RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore, CanonicalStoreError +from airweave.domains.entities.canonical.tests.helpers import observation + + +def query_app(database, context): + """Use the real query service with synthetic HTTP authentication and signing.""" + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_canonical_query_service] = lambda: CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), "synthetic-cursor-secret" + ) + return app + + +async def test_http_read_list_changes_and_cross_tenant_denial(database, source): + capture, fence = source + async with database() as db: + result = await capture.capture( + db, CaptureBatch(fence=fence, records=(observation("one"), observation("two"))) + ) + owner = fence.organization_id + + async def context(): + return SimpleNamespace(organization=SimpleNamespace(id=owner)) + + app = query_app(database, context) + base = f"/sync/{fence.sync_id}/records" + record_path = f"{base}/{result.changes[0].record.id}" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(record_path) + assert response.status_code == 200 + assert response.json()["identity"]["native_id"] == "one" + listed = await client.get(base, params={"limit": 1}) + assert listed.status_code == 200 + assert listed.json()["has_more"] + continued = await client.get( + base, params={"cursor": listed.json()["next_cursor"], "limit": 1} + ) + assert continued.status_code == 200 and not continued.json()["has_more"] + changes = await client.get(base + "/changes") + assert changes.status_code == 200 + assert len(changes.json()["changes"]) == 2 + invalid = await client.get(base, params={"cursor": "not-a-valid-cursor"}) + assert invalid.status_code == 400 + assert invalid.json()["error"]["code"] == "invalid_cursor" + oversized = await client.get(base, params={"limit": 501}) + assert oversized.status_code == 422 + owner = uuid4() + assert (await client.get(record_path)).status_code == 404 + assert (await client.get(base)).status_code == 404 + assert (await client.get(base + "/changes")).status_code == 404 + + +async def test_http_change_continuation_redacts_withdrawn_snapshots_and_rejects_foreign_cursors( + database, source +): + capture, fence = source + parent_identity = RecordIdentity(record_type="calendar", native_id="cal") + parent = observation(identity=parent_identity) + child = observation( + "event", + "cal", + parent=parent_identity, + blobs=(BlobReference(key="immutable", sha256="a" * 64, size_bytes=7),), + ) + async with database() as db: + initial = await capture.capture(db, CaptureBatch(fence=fence, records=(parent, child))) + original_child = initial.changes[1].record + owner = fence.organization_id + + async def context(): + return SimpleNamespace(organization=SimpleNamespace(id=owner)) + + app = query_app(database, context) + base = f"/sync/{fence.sync_id}/records" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + first_response = await client.get(base + "/changes", params={"limit": 1}) + assert first_response.status_code == 200 + first = first_response.json() + assert first["has_more"] and first["high_watermark"] == 2 + assert [change["sequence"] for change in first["changes"]] == [1] + + # Commit withdrawal between pages, before descendant reconciliation. + tombstone = parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}) + async with database() as db: + withdrawn = await capture.capture(db, CaptureBatch(fence=fence, records=(tombstone,))) + assert withdrawn.sequence == 3 + + params = {"cursor": first["next_cursor"], "limit": 1} + continued_response = await client.get(base + "/changes", params=params) + assert continued_response.status_code == 200 + continued = continued_response.json() + assert not continued["has_more"] and continued["high_watermark"] == 2 + assert [change["sequence"] for change in continued["changes"]] == [2] + historical = continued["changes"][0] + assert historical["kind"] == "upsert" + assert historical["record"]["id"] == str(original_child.id) + assert historical["record"]["revision"] == original_child.revision + assert historical["record"]["deleted_at"] is None + assert historical["record"]["content_access"] == "unavailable" + assert historical["record"]["payload"] == {} and historical["record"]["blobs"] == [] + replay = await client.get(base + "/changes", params=params) + assert replay.status_code == 200 and replay.json() == continued + + polled_response = await client.get( + base + "/changes", params={"cursor": continued["next_cursor"], "limit": 1} + ) + assert polled_response.status_code == 200 + polled = polled_response.json() + assert not polled["has_more"] and polled["high_watermark"] == 3 + assert [change["sequence"] for change in polled["changes"]] == [3] + deletion = polled["changes"][0] + assert deletion["kind"] == "delete" + assert deletion["record"]["removal_reason"] == "access_revoked" + assert deletion["record"]["content_access"] == "unavailable" + assert deletion["record"]["payload"] == {} and deletion["record"]["blobs"] == [] + empty = await client.get(base + "/changes", params={"cursor": polled["next_cursor"]}) + assert empty.status_code == 200 + assert empty.json()["changes"] == [] and empty.json()["high_watermark"] == 3 + + foreign_source = await client.get(f"/sync/{uuid4()}/records/changes", params=params) + foreign_operation = await client.get(base, params=params) + owner = uuid4() + foreign_owner = await client.get(base + "/changes", params=params) + for rejected in (foreign_source, foreign_operation, foreign_owner): + assert rejected.status_code == 400 + assert rejected.json()["error"]["code"] == "invalid_cursor" diff --git a/backend/airweave/domains/entities/canonical/tests/test_linear_projection.py b/backend/airweave/domains/entities/canonical/tests/test_linear_projection.py new file mode 100644 index 0000000000..de75b08699 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_linear_projection.py @@ -0,0 +1,150 @@ +"""Original-only Linear projections with no provider calls or fabricated file content.""" + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.platform.entities.linear import LinearLinkedAttachmentEntity + +ISSUE = UUID(int=3) +STAMP = "2026-09-30T00:00:00Z" +CONTEXT = { + "id": str(ISSUE), + "identifier": "ENG-3", + "title": "Native title", + "url": "https://linear.app/example/issue/ENG-3", + "team": {"id": str(UUID(int=2)), "name": "Engineering"}, + "project": None, +} + + +def original(kind, **extra): + native_id = ISSUE if kind == "issue" else UUID(int=4) + payload = { + "id": str(native_id), + "createdAt": STAMP, + "updatedAt": STAMP, + "url": "https://linear.app/native-link", + **extra, + } + if kind == "issue": + payload.update(CONTEXT) + else: + payload["issue"] = CONTEXT + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity( + record_type=kind, + native_id=str(native_id), + container_id=None if kind == "issue" else str(ISSUE), + ), + revision=1, + payload=payload, + payload_schema_version=2, + capture_hash="hash", + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +@pytest.mark.asyncio +async def test_native_issue_and_comment_render_without_provider_or_blob_access(): + for kind, fields in [ + ("issue", {"description": "**native**"}), + ("comment", {"body": "reply", "user": None}), + ]: + record = original(kind, **fields) + storage = AsyncMock() + async with map_record(record, "linear", storage) as entities: + entities = entities.entities + assert entities[0].web_url == record.payload["url"] + assert entities[0].created_at.tzinfo is not None + storage.read_file.assert_not_called() + assert record.payload.get("description", record.payload.get("body")) == next( + iter(fields.values()) + ) + + +@pytest.mark.asyncio +async def test_link_is_explicit_metadata_not_invented_file(): + record = original("attachment", title="Document").model_copy(update={"completeness": "partial"}) + async with map_record(record, "linear", AsyncMock()) as entities: + entities = entities.entities + assert isinstance(entities[0], LinearLinkedAttachmentEntity) + assert "not retained" in entities[0].content_coverage + + +@pytest.mark.asyncio +async def test_missing_binary_cannot_claim_complete(): + with pytest.raises(ValueError, match="without retained bytes"): + async with map_record(original("attachment", title="Document"), "linear", AsyncMock()): + pass + + +@pytest.mark.asyncio +async def test_retained_file_verified_and_disposable(): + record = original("attachment", title="Document") + data = b"%PDF fixture" + digest = hashlib.sha256(data).hexdigest() + ref = BlobReference( + key=f"canonical/{record.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(data), + media_type="application/pdf", + source_path="/url", + ) + record = record.model_copy(update={"blobs": (ref,)}) + storage = AsyncMock() + storage.read_file.return_value = data + async with map_record(record, "linear", storage) as entities: + entities = entities.entities + path = Path(entities[0].local_path) + assert path.read_bytes() == data + assert entities[0].issue_identifier == "ENG-3" + assert not path.exists() + storage.read_file.assert_awaited_once_with(ref.key, max_bytes=len(data)) + storage.read_file.return_value = b"corrupt" + with pytest.raises(ValueError, match="digest"): + async with map_record(record, "linear", storage): + pass + + +@pytest.mark.asyncio +async def test_wrong_parent_or_missing_context_never_invents_metadata(): + record = original("comment", body="reply") + record = record.model_copy( + update={"identity": record.identity.model_copy(update={"container_id": str(UUID(int=99))})} + ) + with pytest.raises(ValueError, match="parent"): + async with map_record(record, "linear", AsyncMock()): + pass + payload = {**record.payload, "issue": {"id": str(ISSUE)}} + with pytest.raises(ValueError): + async with map_record( + record.model_copy(update={"payload": payload}), "linear", AsyncMock() + ): + pass + + +def test_link_projection_is_registered(): + from airweave.domains.entities.registry import EntityDefinitionRegistry + + registry = EntityDefinitionRegistry() + registry.build() + assert registry.get_short_name_by_class(LinearLinkedAttachmentEntity) diff --git a/backend/airweave/domains/entities/canonical/tests/test_linear_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_linear_recovery.py new file mode 100644 index 0000000000..2cd7793155 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_linear_recovery.py @@ -0,0 +1,202 @@ +"""Actual Linear query adapter and SQL capture authority with synthetic provider responses.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import httpx +import pytest + +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run, saved +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.platform.configs.config import LinearConfig +from airweave.platform.sources.linear import LinearSource + +WORKSPACE, TEAM, ISSUE, COMMENT, ATTACHMENT = (UUID(int=n) for n in range(1, 6)) +STAMP = "2026-09-30T00:00:00Z" +ROOT = { + "id": str(ISSUE), + "team": {"id": str(TEAM)}, + "createdAt": STAMP, + "updatedAt": STAMP, + "archivedAt": STAMP, + "trashed": True, +} +CHILD = { + "id": str(COMMENT), + "issue": {"id": str(ISSUE)}, + "createdAt": STAMP, + "updatedAt": STAMP, + "body": "retained original", +} + + +def connection(nodes, *, following=False, cursor=None): + return {"nodes": nodes, "pageInfo": {"hasNextPage": following, "endCursor": cursor}} + + +def result(nodes, *, errors=None, following=False): + return { + "data": {"issues": connection(nodes, following=following)}, + **({"errors": errors} if errors else {}), + } + + +def children(kind, nodes, *, following=False): + return result( + [ + { + **ROOT, + kind: connection( + nodes, following=following, cursor="child-next" if following else None + ), + } + ] + ) + + +async def runner(database, source, responses, *, attempt=1): + service, fence = source + client = AsyncMock() + envelopes = [ + {"data": {"organization": {"id": str(WORKSPACE)}, "viewer": {"id": str(UUID(int=9))}}}, + {"data": {"teams": connection([{"id": str(TEAM)}])}}, + *responses, + ] + client.post.side_effect = [ + httpx.Response( + 200, json=value, request=httpx.Request("POST", "https://api.linear.app/graphql") + ) + for value in envelopes + ] + connector = await LinearSource.create( + auth=StaticTokenProvider("fixture"), + logger=MagicMock(), + http_client=client, + config=LinearConfig(workspace_id=WORKSPACE, team_ids=(TEAM,)), + ) + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + runtime.source, runtime.canonical_capture = connector, pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + return instance, client + + +async def assert_hidden(database, source, rows): + assert all(row.removal_reason == "scope_removed" and row.deleted_at is not None for row in rows) + async with database() as db: + for row in rows: + record = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, row.id + ) + assert record.content_access == "unavailable" and record.payload == {} + + +async def test_zero_outer_child_connection_withdraws_root_and_prior_child(database, source): + instance, client = await runner( + database, source, [result([ROOT]), children("comments", [CHILD]), result([])] + ) + await run(instance) + rows, cursor, _ = await saved(database) + assert {row.native_id for row in rows} == {str(ISSUE), str(COMMENT)} + await assert_hidden(database, source, rows) + assert cursor["canonical_cycle"]["phase"] == "complete" + child_queries = [call.kwargs["json"]["query"] for call in client.post.call_args_list[3:]] + assert all("filter: {id: {eq: $issueId}}" in query for query in child_queries) + + +async def test_zero_exact_membership_reconciles_old_root_and_both_children(database, source): + attachment = {**CHILD, "id": str(ATTACHMENT), "url": "https://external.example/link"} + first, _ = await runner( + database, + source, + [result([ROOT]), children("comments", [CHILD]), children("attachments", [attachment])], + ) + await run(first, checkpoint=False) + rows, _, _ = await saved(database) + assert len(rows) == 3 and all(row.deleted_at is None for row in rows) + # Archived and trashed fields are retained states, never deletion instructions. + async with database() as db: + original = await source[0].store.read( + db, + source[1].organization_id, + source[1].sync_id, + next(row.id for row in rows if row.native_id == str(ISSUE)), + ) + assert original.payload["archivedAt"] == STAMP and original.payload["trashed"] is True + second, _ = await runner(database, source, [result([]), result([])], attempt=2) + await run(second) + rows, cursor, _ = await saved(database) + await assert_hidden(database, source, rows) + assert cursor["canonical_cycle"]["phase"] == "complete" + + +@pytest.mark.parametrize( + "invalid", + [ + result([], errors=[{"extensions": {"type": "INTERNAL_SERVER_ERROR"}}]), + {"data": {"issues": None}}, + result([], following=True), + result([{**ROOT, "id": str(UUID(int=99))}]), + result([ROOT, ROOT]), + {"data": {"issues": {"nodes": []}}}, + ], +) +async def test_invalid_membership_keeps_visible_originals_and_saved_continuation( + database, source, invalid +): + instance, _ = await runner( + database, source, [result([ROOT]), children("comments", [CHILD], following=True), invalid] + ) + with pytest.raises((ValueError, SourceError)): + await run(instance) + rows, cursor, scans = await saved(database) + assert {row.native_id for row in rows} == {str(ISSUE), str(COMMENT)} + assert all(row.deleted_at is None and row.removal_reason is None for row in rows) + assert "canonical_checkpoint" not in cursor + child_scan = next(scan for scan in scans if scan.record_type == "comment") + assert child_scan.phase == "collecting" and child_scan.continuation["after"] == "child-next" + async with database() as db: + for row in rows: + original = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, row.id + ) + assert original.content_access == "available" and original.payload + + +@pytest.mark.parametrize( + "confirmation", + [ + result([ROOT]), + result([], following=True), + result([], errors=[{"extensions": {"type": "INTERNAL_SERVER_ERROR"}}]), + ], +) +async def test_root_omission_cannot_hide_readable_or_unconfirmed_records( + database, source, confirmation +): + first, _ = await runner( + database, + source, + [result([ROOT]), children("comments", [CHILD]), children("attachments", [])], + ) + await run(first, checkpoint=False) + second, _ = await runner(database, source, [result([]), confirmation], attempt=2) + with pytest.raises((ValueError, SourceError)): + await run(second) + rows, cursor, _ = await saved(database) + assert len(rows) == 2 and all(row.deleted_at is None for row in rows) + assert "canonical_checkpoint" not in cursor diff --git a/backend/airweave/domains/entities/canonical/tests/test_mail_http.py b/backend/airweave/domains/entities/canonical/tests/test_mail_http.py new file mode 100644 index 0000000000..01b8c8ee7f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_mail_http.py @@ -0,0 +1,192 @@ +"""Real PostgreSQL mail traversal and HTTP blob authorization over synthetic bytes.""" + +import hashlib +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text + +from airweave.api import deps +from airweave.api.v1.endpoints.records import record_error_response, router +from airweave.db.session import get_db +from airweave.domains.entities.canonical.query import CanonicalQueryService, InvalidRecordCursor +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import BlobReference, CaptureBatch, RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore, CanonicalStoreError +from airweave.domains.entities.canonical.tests.helpers import observation +from airweave.domains.storage.exceptions import StorageConnectionError, StorageNotFoundError +from airweave.models import Sync, SyncJob + + +def message(native_id, created=None, **kwargs): + return observation( + native_id, + identity=RecordIdentity(record_type="message", native_id=native_id), + payload={"id": native_id, "threadId": "same-thread"}, + source_created_at=created, + **kwargs, + ) + + +def service(): + return CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-secret") + + +async def test_thread_chronology_ties_unknown_dates_and_scope(database, source): + capture, fence = source + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + other_sync, other_job = uuid4(), uuid4() + async with database() as db: + db.add(Sync(id=other_sync, organization_id=fence.organization_id, name="Other Gmail")) + await db.flush() + db.add( + SyncJob( + id=other_job, + organization_id=fence.organization_id, + sync_id=other_sync, + status="running", + ) + ) + await db.commit() + other_fence = await capture.activate_writer( + db, fence.organization_id, other_sync, other_job, attempt_id=uuid4(), attempt_number=1 + ) + await capture.capture(db, CaptureBatch(fence=other_fence, records=(message("other", now),))) + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + message("unknown"), + message("unknown2"), + message("b", now, completeness="partial"), + message("a", now), + ), + ), + ) + query = service() + rows, cursor = [], None + async with database() as db: + while True: + page = await query.mail_thread( + db, fence.organization_id, fence.sync_id, "same-thread", limit=1, cursor=cursor + ) + rows.extend(page.messages) + assert page.coverage == "stored_messages_only" + if not page.has_more: + break + cursor = page.next_cursor + assert {r.identity.native_id for r in rows[-2:]} == {"unknown", "unknown2"} + assert rows[-2].id < rows[-1].id + assert rows[0].id < rows[1].id + assert {r.identity.native_id for r in rows} == {"a", "b", "unknown", "unknown2"} + assert all(r.identity.container_id is None for r in rows) + assert any(r.completeness == "partial" for r in rows) + for org, sync, thread in [ + (uuid4(), fence.sync_id, "same-thread"), + (fence.organization_id, other_sync, "same-thread"), + (fence.organization_id, fence.sync_id, "different"), + ]: + with pytest.raises(InvalidRecordCursor): + await query.mail_thread(db, org, sync, thread, cursor=cursor) + other = await query.mail_thread(db, fence.organization_id, other_sync, "same-thread") + assert [r.identity.native_id for r in other.messages] == ["other"] + index = await db.scalar( + text( + "SELECT indexdef FROM pg_indexes WHERE schemaname=current_schema() " + "AND indexname='ix_entity_mail_thread'" + ) + ) + assert "threadId" in index and "source_created_at" in index + + +async def test_http_blob_scope_revision_missing_corrupt_and_revoke_during_io(database, source): + capture, fence = source + content = b"synthetic MIME body" + digest = hashlib.sha256(content).hexdigest() + ref = BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="text/plain", + ) + item = message("one", blobs=(ref,), completeness="partial") + async with database() as db: + result = await capture.capture(db, CaptureBatch(fence=fence, records=(item,))) + record_id = result.changes[0].record.id + storage = AsyncMock() + storage.read_file.return_value = content + owner = fence.organization_id + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def context(): + return SimpleNamespace(organization=SimpleNamespace(id=owner)) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_canonical_query_service] = service + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(storage_backend=storage) + path = f"/sync/{fence.sync_id}/records/{record_id}/blobs/{digest}" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(path, params={"revision": 1}) + assert response.status_code == 200 and response.content == content + assert response.headers["cache-control"] == "private, no-store" + thread = await client.get(f"/sync/{fence.sync_id}/mail/threads/same-thread") + assert ( + thread.status_code == 200 and thread.json()["messages"][0]["completeness"] == "partial" + ) + assert (await client.get(path, params={"revision": 2})).status_code == 409 + assert ( + await client.get(path.replace(digest, "f" * 64), params={"revision": 1}) + ).status_code == 404 + assert ( + await client.get(path.replace(str(fence.sync_id), str(uuid4())), params={"revision": 1}) + ).status_code == 404 + owner = uuid4() + assert (await client.get(path, params={"revision": 1})).status_code == 404 + assert ( + await client.get(f"/sync/{fence.sync_id}/mail/threads/same-thread") + ).status_code == 404 + owner = fence.organization_id + for failure in (StorageNotFoundError("missing"), StorageConnectionError("backend down")): + storage.read_file.side_effect = failure + unavailable = await client.get(path, params={"revision": 1}) + assert unavailable.status_code == 503 + assert unavailable.json()["error"]["code"] == "blob_unavailable" + storage.read_file.side_effect = ValueError("programming error") + with pytest.raises(ValueError, match="programming error"): + await client.get(path, params={"revision": 1}) + storage.read_file.side_effect = None + storage.read_file.return_value = b"corrupt" + assert (await client.get(path, params={"revision": 1})).status_code == 503 + + async def revoke(_, *, max_bytes): + async with database() as db: + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + item.model_copy( + update={"kind": "delete", "removal_reason": "access_revoked"} + ), + ), + ), + ) + return content + + storage.read_file.side_effect = revoke + assert (await client.get(path, params={"revision": 1})).status_code == 404 + hidden = await client.get(f"/sync/{fence.sync_id}/mail/threads/same-thread") + assert hidden.json()["messages"] == [] diff --git a/backend/airweave/domains/entities/canonical/tests/test_metadata_counts.py b/backend/airweave/domains/entities/canonical/tests/test_metadata_counts.py new file mode 100644 index 0000000000..a4c18518f2 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_metadata_counts.py @@ -0,0 +1,74 @@ +"""Metadata distinguishes owned records from independently indexed entity classes.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.search.builders.collection_metadata import CollectionMetadataBuilder + + +async def test_captured_counts_respect_visibility_and_do_not_claim_indexed_counts(database, source): + service, fence = source + parent_id = RecordIdentity(record_type="calendar", native_id="cal") + parent = observation(identity=parent_id) + child = observation("event", "cal", parent=parent_id) + await capture(database, service, fence, parent, child) + query = CanonicalQueryStore() + async with database() as db: + assert await query.captured_counts(db, fence.organization_id, fence.sync_id) == { + "calendar": 1, + "event": 1, + } + assert await query.captured_counts(db, uuid4(), fence.sync_id) == {} + collection_repo = AsyncMock() + collection_repo.get_by_readable_id.return_value = SimpleNamespace(id=uuid4()) + sc_repo = AsyncMock() + sc_repo.get_by_collection_ids.return_value = [ + SimpleNamespace(short_name="google_calendar", sync_id=fence.sync_id, is_authenticated=True) + ] + registry = MagicMock() + registry.get.return_value = SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("calendar", "event")), + federated_search=False, + ) + definitions = MagicMock() + definitions.list_for_source.return_value = [ + SimpleNamespace( + name="GoogleCalendarEventEntity", + short_name="GoogleCalendarEventEntity", + entity_schema={"properties": {"title": {"description": "Event title"}}}, + ) + ] + legacy_counts = AsyncMock() + builder = CollectionMetadataBuilder( + collection_repo, + sc_repo, + registry, + definitions, + legacy_counts, + ) + context = SimpleNamespace(organization=SimpleNamespace(id=fence.organization_id)) + async with database() as db: + metadata = await builder.build(db, context, "calendar") + assert metadata.sources[0].captured_record_counts == {"calendar": 1, "event": 1} + assert metadata.sources[0].entity_types[0].count is None + assert "indexing may lag" in metadata.to_md() + assert "indexed count unknown" in metadata.to_md() + assert "(no entities)" not in metadata.to_md() + legacy_counts.get_counts_per_sync_and_type.assert_not_called() + await capture( + database, + service, + fence, + parent.model_copy( + update={ + "kind": "delete", + "removal_reason": "access_revoked", + } + ), + ) + async with database() as db: + assert await query.captured_counts(db, fence.organization_id, fence.sync_id) == {} diff --git a/backend/airweave/domains/entities/canonical/tests/test_mime_text.py b/backend/airweave/domains/entities/canonical/tests/test_mime_text.py new file mode 100644 index 0000000000..b651483819 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_mime_text.py @@ -0,0 +1,33 @@ +"""Charset recovery never guesses, replaces invalid bytes or overrides valid declarations.""" + +import pytest + +from airweave.domains.entities.canonical.mime_text import decode_mime_text + + +def test_valid_declared_charset_wins_even_when_bytes_also_form_valid_utf8(): + # UTF-8 for café is also valid Latin-1: the explicit declaration remains authoritative. + raw = "café".encode("utf-8") + result = decode_mime_text(raw, "iso-8859-1", "/body") + assert result.text.encode("iso-8859-1") == raw + assert result.recovery is None + + +def test_invalid_declared_charset_recovers_exact_utf8_and_records_evidence(): + raw = "नमस्ते — café".encode("utf-8") + result = decode_mime_text(raw, "us-ascii", "/parts/1") + assert result.text.encode("utf-8") == raw + assert result.recovery.model_dump() == { + "source_path": "/parts/1", + "from_charset": "us-ascii", + "to_charset": "utf-8", + } + + +@pytest.mark.parametrize( + "raw,charset,error", + [(b"\xff", "utf-8", UnicodeDecodeError), (b"valid", "unknown-charset", LookupError)], +) +def test_invalid_or_unknown_encoding_still_fails(raw, charset, error): + with pytest.raises(error): + decode_mime_text(raw, charset, "/body") diff --git a/backend/airweave/domains/entities/canonical/tests/test_mixed_scopes.py b/backend/airweave/domains/entities/canonical/tests/test_mixed_scopes.py new file mode 100644 index 0000000000..f048701256 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_mixed_scopes.py @@ -0,0 +1,589 @@ +"""Real SQL and production page driver with a synthetic mixed-scope source.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.coverage import capture_coverage, mixed_scope_summary +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration, ProviderCheckpoint +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidScopeCheckpoint, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ReconcileScan, ScanContinuation +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.sources.exceptions import SourceServerError +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob + + +class MixedSource: + canonical_record_types = ("calendar", "event", "event_occurrence") + canonical_container_parents = {"event": "calendar", "event_occurrence": "calendar"} + capture_cycle_configuration = CycleConfiguration( + fingerprint="a" * 64, + parents={"calendar": (None,), "event": ("calendar",), "event_occurrence": ("calendar",)}, + scope_changes=("event",), + ) + + def __init__(self, *, fail_window=False, expire=False, window="fixed"): + self.fail_window, self.expire, self.window = fail_window, expire, window + self.calls = [] + self.prepared = 0 + + async def prepare_cycle(self, previous): + self.prepared += 1 + return CapturePlan(mode="mixed", source_plan={"window": self.window}) + + async def prepare_scope(self, scope, cycle, previous, *, parent, force_full): + context = ( + {"parameters": "masters"} + if scope.record_type == "event" + else {"window": cycle.source_plan["window"]} + ) + published = previous.execution.published if previous and previous.execution else None + changes = ( + scope.record_type == "event" and published and published.checkpoint and not force_full + ) + return ScopePlan( + mode="changes" if changes else "full", + request_context=context, + starting_checkpoint=published.checkpoint if changes else None, + ) + + def initial_scope_continuation(self, scope, cycle, plan): + return ScanContinuation( + value={ + "mode": plan.mode, + "token": plan.starting_checkpoint.value if plan.starting_checkpoint else None, + "window": cycle.source_plan["window"], + } + ) + + def child_scope(self, parent, record_type): + return CompletedScope( + record_type=record_type, container_id=parent.identity.native_id, parent=parent.identity + ) + + async def confirm_absent(self, record): + raise AssertionError("Synthetic membership is unchanged") + + async def capture_page(self, scope, continuation, *, files, parent=None): + self.calls.append((scope.record_type, continuation.value)) + if scope.record_type == "event_occurrence" and self.fail_window: + raise SourceServerError("Synthetic interruption", status_code=503) + if scope.record_type == "event" and continuation.value["mode"] == "changes" and self.expire: + self.expire = False + raise InvalidScopeCheckpoint("Synthetic410") + identities = ( + ("cal",) + if scope.record_type == "calendar" + else ("a",) + if continuation.value["mode"] == "changes" + else ("a", "b") + ) + records = tuple( + CaptureRecord( + identity=RecordIdentity( + record_type=scope.record_type, native_id=native, container_id=scope.container_id + ), + payload={"id": native, "mode": continuation.value["mode"]}, + observed_at=datetime.now(timezone.utc), + ) + for native in identities + ) + return CapturePage( + records=records, + continuation=continuation, + final=True, + provider_checkpoint=ProviderCheckpoint(value={"sync_token": str(len(self.calls))}) + if scope.record_type == "event" + else None, + ) + + +async def setup(database, source, connector, attempt=1): + service, fence = source + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + await pipeline.start(ctx) + return pipeline, ctx, runtime + + +async def no_limits(): + pass + + +async def run(pipeline, ctx, runtime): + await pipeline.run_scans(ctx, runtime, no_limits) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + await pipeline.save_checkpoint(ctx, runtime) + + +async def next_job(database, source): + service, fence = source + async with database() as db: + (await db.get(SyncJob, fence.job_id)).status = "completed" + job = SyncJob( + id=uuid4(), + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + db.add(job) + await db.commit() + return service, await service.activate_writer( + db, fence.organization_id, fence.sync_id, job.id, attempt_id=uuid4(), attempt_number=1 + ) + + +async def test_mixed_scope_resume_preserves_baseline_and_fixed_window(database, source): + connector = MixedSource(fail_window=True) + pipeline, ctx, runtime = await setup(database, source, connector) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + event = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + old_full = event.execution_state["last_full"] + assert event.phase == "complete" and old_full is not None + coverage = (await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)))[ + source[1].sync_id + ] + assert coverage.mode == "mixed" and coverage.scope_summary.unfinished == 1 + resumed = MixedSource(window="must-not-replace-active-window") + pipeline, ctx, runtime = await setup(database, source, resumed, 2) + await run(pipeline, ctx, runtime) + assert resumed.prepared == 0 + assert [kind for kind, _ in resumed.calls] == ["calendar", "event_occurrence"] + assert all(progress["window"] == "fixed" for _, progress in resumed.calls) + source = await next_job(database, source) + delta = MixedSource(window="next-window") + pipeline, ctx, runtime = await setup(database, source, delta) + await run(pipeline, ctx, runtime) + assert [progress["mode"] for kind, progress in delta.calls if kind == "event"] == ["changes"] + async with database() as db: + event = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + assert event.execution_state["last_full"] == old_full + assert ( + await db.scalar( + select(Entity).where( + Entity.entity_definition_short_name == "event", Entity.native_id == "b" + ) + ) + ).deleted_at is None + coverage = (await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)))[ + source[1].sync_id + ] + assert coverage.scope_summary.model_dump() == { + "eligible": 3, + "completed_full": 2, + "completed_changes": 1, + "unfinished": 0, + } + assert ( + coverage.last_full_capture is None and coverage.provider_checkpoint_promoted_at is None + ) + assert coverage.discovery == "scope_enumeration_complete" + + +async def test_scope_expiry_restarts_only_event_inventory(database, source): + first = MixedSource() + pipeline, ctx, runtime = await setup(database, source, first) + await run(pipeline, ctx, runtime) + source = await next_job(database, source) + connector = MixedSource(expire=True) + pipeline, ctx, runtime = await setup(database, source, connector) + await run(pipeline, ctx, runtime) + assert [progress["mode"] for kind, progress in connector.calls if kind == "event"] == [ + "changes", + "full", + ] + assert connector.prepared == 1 + assert sum(kind == "calendar" for kind, _ in connector.calls) == 1 + + +async def test_changes_scope_rejects_absence_even_before_cycle_completion(database, source): + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + source = await next_job(database, source) + pipeline, ctx, runtime = await setup(database, source, MixedSource(fail_window=True)) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + state = await source[0].read_scan( + db, pipeline._writer(), CompletedScope(record_type="event", container_id="cal") + ) + assert state.mode == "changes" + with pytest.raises(ScanConflict, match="cannot reconcile"): + await source[0].reconcile_scan( + db, + ReconcileScan( + fence=pipeline._writer(), + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + + +async def test_changed_capture_configuration_cannot_reuse_same_context_baseline(database, source): + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + source = await next_job(database, source) + connector = MixedSource() + connector.capture_cycle_configuration = connector.capture_cycle_configuration.model_copy( + update={"fingerprint": "b" * 64} + ) + pipeline, ctx, runtime = await setup(database, source, connector) + # Synthetic adapter deliberately proposes an old token despite configuration change. + with pytest.raises(ScanConflict, match="compatible full evidence"): + await run(pipeline, ctx, runtime) + async with database() as db: + row = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + assert row.fingerprint == "a" * 64 + + +async def test_forced_mixed_cycle_resumes_intent_across_attempts_and_jobs(database, source): + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + source = await next_job(database, source) + pipeline, ctx, runtime = await setup(database, source, MixedSource(fail_window=True)) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + forced = MixedSource(fail_window=True, window="forced-window") + pipeline, ctx, runtime = await setup(database, source, forced, 2) + ctx.force_full_sync = True + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + assert [p["mode"] for k, p in forced.calls if k == "event"] == ["full"] + async with database() as db: + initial = await source[0].read_cycle(db, pipeline._writer()) + assert initial.force_full_scopes + repeated = MixedSource(fail_window=True, window="wrong") + pipeline, ctx, runtime = await setup(database, source, repeated, 3) + ctx.force_full_sync = True + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + assert repeated.prepared == 0 and not any(k == "event" for k, _ in repeated.calls) + source = await next_job(database, source) + resumed = MixedSource(window="also-wrong") + pipeline, ctx, runtime = await setup(database, source, resumed) + await run(pipeline, ctx, runtime) + async with database() as db: + completed = await source[0].read_cycle(db, pipeline._writer()) + assert completed.version.cycle_id == initial.version.cycle_id + assert completed.source_plan == {"window": "forced-window"} + assert resumed.prepared == 0 + + +async def test_mixed_coverage_wire_from_sql(database, source, tmp_path): + import json + + from airweave.domains.entities.canonical.cycle_models import BeginCycle + + connector = MixedSource(fail_window=True) + pipeline, ctx, runtime = await setup(database, source, connector) + async with database() as db: + await source[0].begin_cycle( + db, + BeginCycle( + fence=pipeline._writer(), + configuration=connector.capture_cycle_configuration, + mode="mixed", + source_plan={"window": "fixed"}, + ), + ) + unknown = (await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)))[ + source[1].sync_id + ] + assert unknown.scope_summary is None and unknown.discovery == "pending" + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + partial = (await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)))[ + source[1].sync_id + ] + assert partial.scope_summary.unfinished == 1 + pipeline, ctx, runtime = await setup(database, source, MixedSource(), 2) + await run(pipeline, ctx, runtime) + async with database() as db: + complete = (await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)))[ + source[1].sync_id + ] + assert complete.scope_summary.unfinished == 0 and complete.phase == "complete" + assert complete.scope_summary.completed_changes == 0 + source = await next_job(database, source) + delta = MixedSource(fail_window=True, window="next-window") + pipeline, ctx, runtime = await setup(database, source, delta) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + assert [value["mode"] for kind, value in delta.calls if kind == "event"] == ["changes"] + async with database() as db: + partial_delta = ( + await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)) + )[source[1].sync_id] + assert partial_delta.phase == "active" and partial_delta.discovery == "pending" + assert partial_delta.scope_summary.model_dump() == { + "eligible": 3, + "completed_full": 1, + "completed_changes": 1, + "unfinished": 1, + } + resumed = MixedSource(window="must-preserve-next-window") + pipeline, ctx, runtime = await setup(database, source, resumed, 2) + await run(pipeline, ctx, runtime) + assert [kind for kind, _ in resumed.calls] == ["calendar", "event_occurrence"] + async with database() as db: + complete_delta = ( + await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)) + )[source[1].sync_id] + assert complete_delta.phase == "complete" + assert complete_delta.discovery == "scope_enumeration_complete" + assert complete_delta.scope_summary.model_dump() == { + "eligible": 3, + "completed_full": 2, + "completed_changes": 1, + "unfinished": 0, + } + assert complete_delta.last_full_capture is None + assert complete_delta.provider_checkpoint_promoted_at is None + unchanged = await db.scalar( + select(Entity).where( + Entity.entity_definition_short_name == "event", Entity.native_id == "b" + ) + ) + assert unchanged.deleted_at is None + (tmp_path / "mixed-coverage.json").write_text( + json.dumps( + [ + item.model_dump(mode="json") + for item in (unknown, partial, complete, partial_delta, complete_delta) + ], + indent=2, + ) + ) + + +async def test_parent_revival_invalidates_published_scope_history(database, source): + from airweave.domains.entities.canonical.requests import CaptureBatch + + pipeline, ctx, runtime = await setup(database, source, MixedSource(fail_window=True)) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + old = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + epoch = old.execution_state["last_full"]["parent_visibility_epoch"] + await source[0].capture( + db, + CaptureBatch( + fence=pipeline._writer(), + records=( + CaptureRecord( + identity=RecordIdentity(record_type="calendar", native_id="cal"), + payload={"id": "cal"}, + kind="delete", + removal_reason="access_revoked", + observed_at=datetime.now(timezone.utc), + ), + ), + ), + ) + await source[0].capture( + db, + CaptureBatch( + fence=pipeline._writer(), + records=( + CaptureRecord( + identity=RecordIdentity(record_type="calendar", native_id="cal"), + payload={"id": "cal"}, + observed_at=datetime.now(timezone.utc), + ), + ), + ), + ) + restored = MixedSource() + pipeline, ctx, runtime = await setup(database, source, restored, 2) + await run(pipeline, ctx, runtime) + assert [p["mode"] for k, p in restored.calls if k == "event"] == ["full"] + async with database() as db: + row = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + assert row.execution_state["last_full"]["parent_visibility_epoch"] > epoch + + +@pytest.mark.parametrize("race", ["owner", "writer"]) +async def test_awaited_scope_plan_cannot_commit_after_owner_or_writer_change( + database, source, race +): + from airweave.domains.entities.canonical.requests import CaptureBatch + from airweave.domains.entities.canonical.store import StaleWriter + + service, fence = source + + class RacingSource(MixedSource): + async def prepare_scope(self, scope, cycle, previous, *, parent, force_full): + plan = await super().prepare_scope( + scope, cycle, previous, parent=parent, force_full=force_full + ) + if scope.record_type == "event": + async with database() as db: + if race == "writer": + await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + else: + await service.capture( + db, + CaptureBatch( + fence=fence, + records=( + CaptureRecord( + identity=parent.identity, + payload={"id": "cal", "route": "changed"}, + observed_at=datetime.now(timezone.utc), + ), + ), + ), + ) + return plan + + pipeline, ctx, runtime = await setup(database, source, RacingSource()) + with pytest.raises(StaleWriter if race == "writer" else ScanConflict): + await run(pipeline, ctx, runtime) + async with database() as db: + assert ( + await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) is None + ) + assert ( + await db.scalar(select(Entity).where(Entity.entity_definition_short_name == "event")) + is None + ) + + +async def test_nonmixed_replacement_clears_prior_scope_evidence(database, source): + from airweave.domains.entities.canonical.cycle_models import BeginCycle + from airweave.domains.entities.canonical.scan_models import BeginScan + + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + async with database() as db: + service = source[0] + fence = pipeline._writer() + previous = await service.read_cycle(db, fence) + config = previous.configuration.model_copy( + update={"fingerprint": "b" * 64, "scope_changes": ()} + ) + current = await service.begin_cycle( + db, BeginCycle(fence=fence, configuration=config, expected=previous.version) + ) + scope = CompletedScope(record_type="calendar") + old = await service.read_scan(db, fence, scope) + assert old.execution is not None + new = await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=scope, + cycle_id=current.version.cycle_id, + fingerprint=config.fingerprint, + expected=old.version, + ), + ) + assert new.execution is None + + +async def test_completed_coverage_survives_job_completion_but_not_new_writer(database, source): + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + service, fence = source + async with database() as db: + (await db.get(SyncJob, fence.job_id)).status = "completed" + await db.commit() + coverage = await capture_coverage(db, fence.organization_id, (fence.sync_id,)) + assert coverage[fence.sync_id].phase == "complete" + assert coverage[fence.sync_id].scope_summary.unfinished == 0 + source = await next_job(database, source) + async with database() as db: + coverage = await capture_coverage(db, fence.organization_id, (fence.sync_id,)) + assert fence.sync_id not in coverage + pipeline, ctx, runtime = await setup(database, source, MixedSource(fail_window=True)) + with pytest.raises(SourceServerError): + await run(pipeline, ctx, runtime) + async with database() as db: + coverage = await capture_coverage(db, fence.organization_id, (fence.sync_id,)) + assert coverage[fence.sync_id].phase == "active" + assert coverage[fence.sync_id].scope_summary.unfinished == 1 + + +@pytest.mark.parametrize( + "change", ["stale_root_inventory", "unknown_root_mode", "parent_epoch", "leaf_attempt"] +) +async def test_coverage_root_batch_preserves_scope_evidence(database, source, change): + pipeline, ctx, runtime = await setup(database, source, MixedSource()) + await run(pipeline, ctx, runtime) + service, fence = source + async with database() as db: + cycle = await service.read_cycle(db, fence) + root = await db.scalar( + select(CaptureScan).where( + CaptureScan.sync_id == fence.sync_id, CaptureScan.record_type == "calendar" + ) + ) + if change == "stale_root_inventory": + root.membership_attempt_id = uuid4() + elif change == "unknown_root_mode": + root.execution_state = None + elif change == "parent_epoch": + parent = await db.scalar( + select(Entity).where( + Entity.sync_id == fence.sync_id, + Entity.entity_definition_short_name == "calendar", + ) + ) + parent.visibility_epoch += 1 + else: + leaf = await db.scalar( + select(CaptureScan).where( + CaptureScan.sync_id == fence.sync_id, CaptureScan.record_type == "event" + ) + ) + leaf.membership_attempt_id = uuid4() + await db.flush() + + summary = await mixed_scope_summary(db, fence.organization_id, fence.sync_id, cycle) + if change == "stale_root_inventory": + assert summary is None + else: + expected_unfinished = {"unknown_root_mode": 1, "parent_epoch": 2, "leaf_attempt": 0} + assert summary.eligible == 3 + assert summary.unfinished == expected_unfinished[change] + assert summary.completed_full == 3 - summary.unfinished + assert await mixed_scope_summary(db, uuid4(), fence.sync_id, cycle) is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_native_text.py b/backend/airweave/domains/entities/canonical/tests/test_native_text.py new file mode 100644 index 0000000000..f35b9e239f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_native_text.py @@ -0,0 +1,163 @@ +"""Source-selected native bodies share the retained and indexed construction.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.tests.test_slack_projection import message +from airweave.domains.entities.canonical.text_models import TextArtifact +from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder + + +@pytest.mark.parametrize("schema", [1, 2]) +@pytest.mark.parametrize( + "text", ["", " \n", "Hello <@U1> 👋\n*bold* \n# Content\nend"] +) +async def test_native_body_exact_and_excluded_from_metadata(schema, text): + record = message([]).model_copy( + update={ + "payload": {"ts": "1.000001", "text": text, "blocks": [{"type": "unrendered"}]}, + "payload_schema_version": schema, + } + ) + async with map_record(record, "slack", AsyncMock()) as mapped: + item = mapped.parts[0] + assert item.entity.text == text + batch = await TextualRepresentationBuilder(MagicMock()).build_with_text( + [item.entity], + SimpleNamespace(source_short_name="slack", logger=MagicMock()), + SimpleNamespace(entity_tracker=SimpleNamespace(record_skipped=AsyncMock())), + native_bodies={item.entity.entity_id: item.native_body}, + ) + built = batch.representations[0] + assert built.kind == "native_text" + assert built.text[built.content_start :] == text + assert "**Text**:" not in built.text[: built.content_start] + assert built.text == batch.entities[0].textual_representation + assert "unrendered" not in built.text + + +async def test_missing_text_does_not_invent_native_body_from_blocks_or_title(): + record = message([]).model_copy( + update={ + "payload": {"ts": "1.000001", "blocks": [{"type": "rich_text"}]}, + } + ) + async with map_record(record, "slack", AsyncMock()) as mapped: + assert mapped.parts[0].native_body is None + assert mapped.parts[0].entity.text == "" + + +@pytest.mark.parametrize("text", [None, 123, {}, []]) +async def test_malformed_native_body_fails(text): + record = message([]).model_copy(update={"payload": {"ts": "1.000001", "text": text}}) + with pytest.raises(ValueError, match="Slack message text must be a string"): + async with map_record(record, "slack", AsyncMock()): + pass + + +@pytest.mark.parametrize("start,kind", [(None, "generated_text"), (0, "extracted_text")]) +def test_old_manifest_provenance_is_preserved(start, kind): + values = { + "id": uuid4(), + "part_index": 0, + "sha256": "0" * 64, + "size_bytes": 0, + "characters": 0, + "content_start": start, + } + assert TextArtifact.model_validate(values).kind == kind + for incorrect in ["native_text", "extracted_text"] if start is None else ["generated_text"]: + with pytest.raises(ValidationError, match="provenance"): + TextArtifact.model_validate({**values, "kind": incorrect}) + + +@pytest.mark.parametrize("body", [None, "", "Native 👋 text\n# Content\n<@U1>"]) +async def test_slack_native_retained_read_matches_text_used_for_index( + database, source, tmp_path, body +): + from sqlalchemy import update + + from airweave.adapters.storage.filesystem import FilesystemBackend + from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore + from airweave.domains.entities.canonical.query import CanonicalQueryService + from airweave.domains.entities.canonical.query_store import CanonicalQueryStore + from airweave.domains.entities.canonical.requests import RecordIdentity + from airweave.domains.entities.canonical.store import CanonicalRecordStore + from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + observation, + ) + from airweave.domains.entities.canonical.tests.test_extraction_coverage import ( + destination, + projector, + ) + from airweave.domains.entities.canonical.text_query import CanonicalTextReader, TextUnavailable + from airweave.models.sync import Sync + + service, fence = source + binding = await bind_projection(database, fence, "slack") + original = observation( + identity=RecordIdentity(record_type="message", native_id="1.000001", container_id="C1"), + payload={"ts": "1.000001", **({"text": body} if body is not None else {})}, + payload_schema_version=2, + ) + await capture(database, service, fence, original) + storage = FilesystemBackend(tmp_path) + projection = projector(database, storage) + indexed = [] + + async def fixed_chunks(entities, context, runtime): + indexed.extend(entity.textual_representation for entity in entities) + return projection._processor._multiply_entities( + entities, + [[{"text": entity.textual_representation}] for entity in entities], + context, + ) + + projection._processor._chunk_entities = fixed_chunks + async with database() as db: + work = (await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id))[ + 0 + ] + assert await projection.project_one( + work, "slack", destination(binding.collection_id), MagicMock() + ) + reader = CanonicalTextReader( + CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "key"), + storage, + ) + async with database() as db: + ref = ( + await reader.list(db, fence.organization_id, fence.sync_id, work.record.id, 1) + ).representations[0] + if body is None: + assert ref.kind == "generated_text" and ref.content_characters is None + else: + assert ref.kind == "native_text" and ref.content_characters == len(body) + args = (fence.organization_id, fence.sync_id, work.record.id, 1, ref.generation, ref.id) + if body is None: + with pytest.raises(TextUnavailable, match="generated search text"): + await reader.read(db, *args) + else: + content = await reader.read(db, *args) + assert content.text == body and content.total_characters == len(body) + index = await reader.read(db, *args, view="index") + assert index.text == indexed[0] + if body is not None: + assert index.text.endswith(body) + if body: + first = await reader.read(db, *args, limit=8) + rest = await reader.read(db, *args, offset=first.next_offset) + assert first.text + rest.text == body + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(index_pipeline_version=2) + ) + await db.commit() + with pytest.raises(TextUnavailable): + await reader.read(db, *args) diff --git a/backend/airweave/domains/entities/canonical/tests/test_notion_projection.py b/backend/airweave/domains/entities/canonical/tests/test_notion_projection.py new file mode 100644 index 0000000000..d303af71db --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_notion_projection.py @@ -0,0 +1,322 @@ +"""Retained native Notion text, explicit limits, and existing publication authority.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import ( + ProjectionMappingError, + map_record, +) +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + publication_matches, +) +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + observation, + publish_prepared, +) +from airweave.models import Entity, Sync + +PAGE, BLOCK = str(UUID(int=1)), str(UUID(int=2)) +STAMP = "2026-09-30T00:00:00Z" + + +def rich(text, kind="text"): + return {"type": kind, "plain_text": text} + + +def original(kind="page", **fields): + native = BLOCK if kind == "block" else PAGE + payload = { + "object": kind, + "id": native, + "created_time": STAMP, + "last_edited_time": STAMP, + "in_trash": False, + **fields, + } + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type=kind, native_id=native), + parent=RecordIdentity(record_type="page", native_id=PAGE) if kind == "block" else None, + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="hash", + content_hash=None, + completeness="partial", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +def block(kind, content, **fields): + return original( + "block", + type=kind, + has_children=False, + parent={"type": "page_id", "page_id": PAGE}, + **{kind: content}, + **fields, + ) + + +async def mapped(record): + storage = AsyncMock() + async with map_record(record, "notion", storage) as entities: + entities = entities.entities + assert len(entities) == 1 + entity = entities[0] + assert not storage.mock_calls + assert record.completeness == "partial" + assert entity.native_id == record.identity.native_id + return entity + + +@pytest.mark.asyncio +async def test_root_metadata_is_not_an_assembled_page_body(): + record = original( + properties={ + "Name": {"type": "title", "title": [rich("Launch "), rich("@Mira", "mention")]} + }, + url=f"https://app.notion.com/p/{PAGE}", + unknown={"private": "not prose"}, + ) + entity = await mapped(record) + assert entity.title == "Launch @Mira" and entity.text == "" + assert entity.web_url == record.payload["url"] + assert entity.created_at.tzinfo is not None + assert "Metadata only" in entity.content_coverage + for kind in ("database", "data_source"): + fields = {"title": [rich("Plans")], "description": [rich("Roadmap")]} + if kind == "data_source": + fields["properties"] = { + "Name": {"type": "title", "title": {}}, + "Stage": {"type": "status", "status": {}}, + } + entity = await mapped(original(kind, **fields)) + assert entity.title == "Plans" and "Roadmap" in entity.text + assert ("Stage (status)" in entity.text) == (kind == "data_source") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind,content,expected", + [ + ( + "paragraph", + {"rich_text": [rich("Hi "), rich("@Mira", "mention"), rich(" x²", "equation")]}, + "Hi @Mira x²", + ), + ("heading_2", {"rich_text": [rich("Milestones")]}, "Milestones"), + ("to_do", {"checked": True, "rich_text": [rich("Ship")]}, "[x] Ship"), + ( + "code", + { + "language": "python", + "rich_text": [rich("print('hello')\n")], + "caption": [rich("Example")], + }, + "python\nprint('hello')\n\nExample", + ), + ("table_row", {"cells": [[rich("Owner")], [rich("Mira")]]}, "Owner | Mira"), + ("equation", {"expression": "E=mc^2"}, "E=mc^2"), + ], +) +async def test_supported_native_block_text(kind, content, expected): + entity = await mapped(block(kind, content)) + assert entity.text == expected + assert entity.web_url == "" + assert "This block only" in entity.content_coverage + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind,content", + [ + ("table", {"table_width": 2, "has_column_header": True, "has_row_header": False}), + ("column", {}), + ("divider", {}), + ("synced_block", {"synced_from": None}), + ], +) +async def test_known_structure_has_explicit_empty_body(kind, content): + entity = await mapped(block(kind, content)) + assert entity.title and entity.text == "" + assert any(word in entity.content_coverage for word in ("only", "separate")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["child_page", "child_database"]) +async def test_native_reference_label_does_not_duplicate_target_body(kind): + entity = await mapped(block(kind, {"title": "Budget"})) + assert entity.title == "Budget" and entity.text == "" + assert entity.web_url == "" and "Reference label only" in entity.content_coverage + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "record", + [ + block("future_type", {"secret": "never serialize me"}), + block("synced_block", {"synced_from": {"type": "block_id", "block_id": PAGE}}), + block("paragraph", {"rich_text": [{"type": "future", "plain_text": "unknown"}]}), + block( + "paragraph", + {"rich_text": [{"type": "text", "text": {"content": "not retained plain text"}}]}, + ), + ], +) +async def test_unsupported_or_malformed_content_never_becomes_empty_success(record): + with pytest.raises(ValueError): + await mapped(record) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "javascript:alert(1)", + "https://evil.example/page", + "https://app.notion.com.evil.example/page", + "https://user@app.notion.com/page", + "http://notion.so/page", + ], +) +async def test_untrusted_navigation_remains_pending(url): + with pytest.raises(ValueError): + await mapped(original(properties={"title": {"type": "title", "title": []}}, url=url)) + + +@pytest.mark.asyncio +async def test_identity_and_authority_mismatches_are_rejected(): + record = block("paragraph", {"rich_text": [rich("private")]}) + cases = [ + record.model_copy(update={"parent": None}), + record.model_copy( + update={"identity": record.identity.model_copy(update={"native_id": PAGE})} + ), + record.model_copy(update={"content_access": "parent_unavailable"}), + record.model_copy(update={"deleted_at": datetime.now(timezone.utc)}), + record.model_copy(update={"payload": {**record.payload, "in_trash": True}}), + ] + for case in cases: + with pytest.raises(ProjectionMappingError): + await mapped(case) + + +def test_original_search_view_is_in_existing_entity_registry(): + from airweave.domains.entities.registry import EntityDefinitionRegistry + from airweave.platform.entities.notion import NotionOriginalEntity + + registry = EntityDefinitionRegistry() + registry.build() + assert registry.get_short_name_by_class(NotionOriginalEntity) == "notion_original_entity" + assert (NotionOriginalEntity.model_fields["content_coverage"].json_schema_extra or {}).get( + "embeddable" + ) is not True + + +@pytest.mark.integration +async def test_notion_publication_uses_current_revision_and_ancestor_authority(database, source): + service, fence = source + await bind_projection(database, fence, "notion") + root = original(properties={"title": {"type": "title", "title": [rich("Page")]}}) + child = block("paragraph", {"rich_text": [rich("First version")]}) + root_observation = observation( + identity=root.identity, payload=root.payload, completeness="partial" + ) + child_observation = observation( + identity=child.identity, parent=root.identity, payload=child.payload, completeness="partial" + ) + await capture(database, service, fence, root_observation, child_observation) + store = CanonicalProjectionStore() + async with database() as db: + pending = await store.pending(db, fence.organization_id, fence.sync_id) + work = next(item for item in pending if item.record.identity.record_type == "block") + assert (await mapped(work.record)).text == "First version" + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1) + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ) + updated = child_observation.model_copy( + update={"payload": {**child.payload, "paragraph": {"rich_text": [rich("Changed")]}}} + ) + await capture(database, service, fence, updated) + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + is None + ) + assert not await publish_prepared(store, db, work, uuid4(), 1) + pending = await store.pending(db, fence.organization_id, fence.sync_id) + fresh = next(item for item in pending if item.record.identity.record_type == "block") + await capture( + database, + service, + fence, + root_observation.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + async with database() as db: + assert not await publish_prepared(store, db, fresh, uuid4(), 1) + + +@pytest.mark.integration +async def test_unknown_notion_block_stays_pending_in_actual_projector(database, source): + service, fence = source + binding = await bind_projection(database, fence, "notion") + root = original(properties={"title": {"type": "title", "title": []}}) + child = block("future_type", {"body": "not raw JSON prose"}) + await capture( + database, + service, + fence, + observation(identity=root.identity, payload=root.payload, completeness="partial"), + observation( + identity=child.identity, + parent=root.identity, + payload=child.payload, + completeness="partial", + ), + ) + store = CanonicalProjectionStore() + async with database() as db: + pending = await store.pending(db, fence.organization_id, fence.sync_id) + root_work = next(item for item in pending if item.record.identity.record_type == "page") + assert await publish_prepared(store, db, root_work, uuid4(), 1) + processor, destination = MagicMock(), MagicMock(collection_id=binding.collection_id) + projector = CanonicalProjector(store, database, processor, AsyncMock()) + result = await projector.batch( + fence.organization_id, fence.sync_id, "notion", destination, MagicMock() + ) + assert result.failed == 1 and result.published == 0 + processor.build_text.assert_not_called() + destination.feed_prepared.assert_not_called() + async with database() as db: + pending = await store.pending(db, fence.organization_id, fence.sync_id) + assert len(pending) == 1 + row = await db.get(Entity, pending[0].record.id) + assert row.projection_error == "ProjectionMappingError" and row.indexed_revision is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_notion_property_projection.py b/backend/airweave/domains/entities/canonical/tests/test_notion_property_projection.py new file mode 100644 index 0000000000..2ce6953fe2 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_notion_property_projection.py @@ -0,0 +1,317 @@ +"""Offline native properties, bounded reads and retained identity.""" + +import hashlib +import json +from unittest.mock import AsyncMock + +import pytest + +from airweave.domains.entities.canonical.projection_mappers import ( + ProjectionMappingError, + map_record, +) +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import bind_projection +from airweave.domains.entities.canonical.tests.test_notion_projection import PAGE, STAMP, original + + +def item(kind, value): + return {"object": "property_item", "id": "a%3Ab", "type": kind, kind: value} + + +def page(kind, values, more=False, calculation=None): + metadata = {"id": "a%3Ab", "type": kind, "next_url": "next" if more else None} + if calculation is not None: + metadata[kind] = calculation + return { + "object": "list", + "type": "property_item", + "results": values, + "property_item": metadata, + "has_more": more, + "next_cursor": "next" if more else None, + } + + +def fixture(kind, responses, status="available"): + record = original() + header = { + "format_version": 1, + "page_id": PAGE, + "property_id": "a%3Ab", + "notion_version": "2026-03-11", + "page_last_edited_time": STAMP, + } + content = json.dumps({**header, "responses": responses}, ensure_ascii=False).encode() + digest = hashlib.sha256(content).hexdigest() + blob = BlobReference( + key=f"canonical/{record.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="application/json", + ) + record = record.model_copy( + update={ + "identity": RecordIdentity( + record_type="page_property", native_id="a%3Ab", container_id=PAGE + ), + "parent": RecordIdentity(record_type="page", native_id=PAGE), + "payload_schema_version": 2, + "payload": { + **header, + "name": "Résumé", + "property": {"id": "a%3Ab", "type": kind}, + "response_count": len(responses), + "value_status": status, + }, + "blobs": (blob,), + } + ) + return record, content + + +async def project(record, content): + storage = AsyncMock() + storage.read_file.return_value = content + async with map_record(record, "notion", storage) as result: + return result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind,value,expected", + [ + ("number", 12, "12"), + ("number", 9007199254740993, "9007199254740993"), + ("checkbox", False, "false"), + ("select", {"name": "準備"}, "準備"), + ("date", {"start": "2026-10-01"}, "2026-10-01"), + ("people", {"id": PAGE, "name": "Mira"}, f"Mira ({PAGE})"), + ("relation", {"id": PAGE}, PAGE), + ( + "files", + [{"name": "Report.pdf", "file": {"url": "https://signed.example/secret"}}], + "Report.pdf", + ), + ("formula", {"type": "string", "string": "計画"}, "計画"), + ], +) +async def test_native_values(kind, value, expected): + responses = ( + [page(kind, [item(kind, value)])] if kind in {"people", "relation"} else [item(kind, value)] + ) + record, content = fixture(kind, responses) + result = await project(record, content) + entity = result.entities[0] + assert entity.text == expected and entity.native_id == "a%3Ab" + assert entity.created_at is None and entity.updated_at is None + assert result.parts[0].native_body is None + assert "exclude linked bodies" in entity.content_coverage + + +@pytest.mark.asyncio +async def test_paginated_text_and_terminal_rollup(): + record, content = fixture( + "rich_text", + [ + page("rich_text", [item("rich_text", {"type": "text", "plain_text": "你好 "})], True), + page("rich_text", [item("rich_text", {"type": "mention", "plain_text": "Mira"})]), + ], + ) + assert (await project(record, content)).entities[0].text == "你好 Mira" + record, content = fixture( + "rollup", + [ + page("rollup", [], True, {"type": "incomplete", "function": "sum"}), + page("rollup", [], calculation={"type": "number", "number": 9, "function": "sum"}), + ], + ) + assert (await project(record, content)).entities[0].text == "9" + + +@pytest.mark.asyncio +async def test_unsupported_and_incomplete_distinct(): + record, content = fixture("formula", [item("formula", {"type": "unsupported"})], "unsupported") + result = await project(record, content) + assert not result.entities and result.parts[0].omission == "unsupported_format" + record, content = fixture("rollup", [page("rollup", [], calculation={"type": "incomplete"})]) + with pytest.raises(ProjectionMappingError, match="incomplete"): + await project(record, content) + + +@pytest.mark.asyncio +async def test_authority_hash_and_pagination_fail_closed(): + record, content = fixture("number", [item("number", 1)]) + for change in [ + {"parent": None}, + {"payload_schema_version": 1}, + {"payload": {**record.payload, "response_count": 2}}, + {"payload": {**record.payload, "page_last_edited_time": "2026-10-02T00:00:00Z"}}, + ]: + with pytest.raises(ValueError): + await project(record.model_copy(update=change), content) + with pytest.raises(ValueError): + await project(record, content + b" ") + record, content = fixture("rich_text", [page("rich_text", [], True)]) + with pytest.raises(ProjectionMappingError, match="pagination"): + await project(record, content) + + +@pytest.mark.asyncio +async def test_budget_before_storage(): + record, _ = fixture("number", [item("number", 1)]) + record = record.model_copy( + update={"blobs": (record.blobs[0].model_copy(update={"size_bytes": 16 * 1024 * 1024 + 1}),)} + ) + storage = AsyncMock() + with pytest.raises(ProjectionMappingError, match="projection budget"): + async with map_record(record, "notion", storage): + pass + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind,responses", + [ + ("future_type", [item("future_type", None)]), + ("number", [page("number", [])]), + ("people", [item("people", {"id": PAGE})]), + ("checkbox", [item("checkbox", None)]), + ], +) +async def test_unknown_or_invalid_native_shape_fails(kind, responses): + record, content = fixture(kind, responses) + with pytest.raises(ValueError): + await project(record, content) + + +@pytest.mark.integration +async def test_property_publication_obeys_current_parent(database, source): + from uuid import uuid4 + + from sqlalchemy import select + + from airweave.domains.entities.canonical.projection_models import ProjectionLocator + from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + publication_matches, + ) + from airweave.domains.entities.canonical.tests.helpers import ( + capture, + observation, + publish_prepared, + ) + from airweave.models import Entity, Sync + + service, fence = source + await bind_projection(database, fence, "notion") + record, content = fixture("number", [item("number", 1)]) + blob = record.blobs[0].model_copy( + update={"key": f"canonical/{fence.sync_id}/blobs/sha256/{record.blobs[0].sha256}"} + ) + root = observation(identity=record.parent, payload={"id": PAGE}) + await capture( + database, + service, + fence, + root, + observation( + identity=record.identity, + parent=record.parent, + payload=record.payload, + payload_schema_version=2, + blobs=(blob,), + completeness="partial", + ), + ) + store = CanonicalProjectionStore() + async with database() as db: + work = next( + w + for w in await store.pending(db, fence.organization_id, fence.sync_id) + if w.record.identity.record_type == "page_property" + ) + assert (await project(work.record, content)).entities[0].text == "1" + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1) + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ) + await capture( + database, + service, + fence, + root.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + is None + ) + assert not await publish_prepared(store, db, work, uuid4(), 1) + + +@pytest.mark.integration +async def test_unsupported_property_publishes_explicit_extraction(database, source, tmp_path): + from sqlalchemy import select + + from airweave.adapters.storage.filesystem import FilesystemBackend + from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + current_extraction, + ) + from airweave.domains.entities.canonical.tests.helpers import capture, observation + from airweave.domains.entities.canonical.tests.test_extraction_coverage import ( + destination, + logger, + projector, + ) + from airweave.models import Entity + + service, fence = source + binding = await bind_projection(database, fence, "notion") + record, content = fixture("formula", [item("formula", {"type": "unsupported"})], "unsupported") + blob = record.blobs[0].model_copy( + update={"key": f"canonical/{fence.sync_id}/blobs/sha256/{record.blobs[0].sha256}"} + ) + storage = FilesystemBackend(tmp_path) + await storage.write_file(blob.key, content) + await capture( + database, + service, + fence, + observation(identity=record.parent, payload={"id": PAGE}), + observation( + identity=record.identity, + parent=record.parent, + payload=record.payload, + payload_schema_version=2, + blobs=(blob,), + completeness="partial", + ), + ) + async with database() as db: + work = next( + w + for w in await CanonicalProjectionStore().pending( + db, fence.organization_id, fence.sync_id + ) + if w.record.identity.record_type == "page_property" + ) + assert await projector(database, storage).project_one( + work, "notion", destination(binding.collection_id), logger + ) + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.id == work.record.id)) + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + assert row.indexed_chunk_count == 0 + assert coverage.parts[0].outcome == "unsupported" + assert coverage.status == "unavailable" diff --git a/backend/airweave/domains/entities/canonical/tests/test_notion_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_notion_recovery.py new file mode 100644 index 0000000000..8ba8e657fa --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_notion_recovery.py @@ -0,0 +1,397 @@ +"""Real Notion pipeline and PostgreSQL recovery with synthetic native HTTP only.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run, saved +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.entity import Entity +from airweave.platform.configs.config import NotionConfig +from airweave.platform.sources.notion import NotionSource + +PAGE, DATA, DATABASE, BLOCK1, BLOCK2 = (str(UUID(int=i)) for i in range(1, 6)) +STAMP = "2026-09-30T00:00:00Z" + + +def native(kind, identity, **fields): + return { + "object": kind, + "id": identity, + "created_time": STAMP, + "last_edited_time": STAMP, + "in_trash": False, + "parent": {"type": "workspace", "workspace": True}, + **fields, + } + + +def listing(rows=(), cursor=None): + return { + "object": "list", + "results": list(rows), + "next_cursor": cursor, + "has_more": cursor is not None, + } + + +class NativeFixture: + """Endpoint router only; production source, pipeline, driver and SQL stay real.""" + + def __init__(self): + self.interrupt = True + self.calls = [] + self.page = native("page", PAGE, properties={}) + self.data = native( + "data_source", DATA, parent={"type": "database_id", "database_id": DATABASE} + ) + self.database = native("database", DATABASE, data_sources=[{"id": DATA}]) + self.blocks = [ + native( + "block", + item, + type="paragraph", + has_children=False, + parent={"type": "page_id", "page_id": PAGE}, + paragraph={"rich_text": [{"plain_text": "retained"}]}, + ) + for item in (BLOCK1, BLOCK2) + ] + + async def request(self, url, *, headers, json=None, params=None): + assert headers["Notion-Version"] == "2026-03-11" + path = url.removeprefix("https://api.notion.com/v1/") + self.calls.append((path, json, params)) + if path == "search": + payload = listing([self.data] if json["filter"]["value"] == "data_source" else []) + elif path == f"data_sources/{DATA}": + payload = self.data + elif path == f"databases/{DATABASE}": + payload = self.database + elif path == f"data_sources/{DATA}/query": + payload = listing([self.page]) + elif path == f"pages/{PAGE}": + payload = self.page + elif path == f"blocks/{PAGE}/children": + if params.get("start_cursor"): + if self.interrupt: + raise ConnectionError("synthetic interruption after committed block page") + payload = listing([self.blocks[1]]) + else: + payload = listing([self.blocks[0]], "block-page-2") + else: + raise AssertionError(f"Unexpected synthetic request: {path}") + return httpx.Response(200, json=payload, request=httpx.Request("GET", url)) + + +async def runner(database, source, fixture, attempt=1, files=None): + service, fence = source + client = AsyncMock() + client.get.side_effect = fixture.request + client.post.side_effect = fixture.request + connector = await NotionSource.create( + auth=StaticTokenProvider("fixture"), + logger=MagicMock(), + http_client=client, + config=NotionConfig(expected_workspace_id=UUID(int=100), expected_bot_id=UUID(int=101)), + ) + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=files or MagicMock(), + ) + runtime.source, runtime.canonical_capture = connector, pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + return instance, connector + + +async def test_discovered_roots_reach_frontier_and_interrupted_membership_refreshes( + database, source +): + fixture = NativeFixture() + first, _ = await runner(database, source, fixture) + with pytest.raises(ConnectionError, match="synthetic interruption"): + await run(first) + rows, checkpoint, scans = await saved(database) + assert "canonical_checkpoint" not in checkpoint + assert all( + row.parent_record_type is None + for row in rows + if row.entity_definition_short_name != "block" + ) + scan = next( + row for row in scans if row.record_type == "block" and row.continuation.get("cursor") + ) + assert scan.continuation["cursor"] == "block-page-2" and scan.phase == "collecting" + before = len(fixture.calls) + fixture.interrupt = False + second, _ = await runner(database, source, fixture, attempt=2) + await run(second) + later = fixture.calls[before:] + block_calls = [item for item in later if item[0] == f"blocks/{PAGE}/children"] + # Block inventories are themselves membership authorities. A new attempt + # refreshes the whole exact inventory; durable originals survive the failure. + assert len(block_calls) == 2 + assert "start_cursor" not in block_calls[0][2] + assert block_calls[1][2]["start_cursor"] == "block-page-2" + rows, checkpoint, _ = await saved(database) + assert {row.native_id for row in rows} == {PAGE, DATA, DATABASE, BLOCK1, BLOCK2} + assert all(row.deleted_at is None for row in rows) + assert all(row.completeness == "partial" for row in rows) + assert checkpoint["canonical_cycle"]["phase"] == "complete" + assert ( + checkpoint["canonical_cycle"]["configuration"]["completion_policies"]["page"] + == "discovery_with_validation" + ) + # No query-scope ancestry was assigned to independently visible pages/data sources. + assert all( + row.parent_record_type is None + for row in rows + if row.entity_definition_short_name in {"page", "data_source", "database"} + ) + + +async def test_root_omission_exact_refresh_preserves_visible_page_and_current_native_payload( + database, source +): + fixture = NativeFixture() + fixture.interrupt = False + instance, connector = await runner(database, source, fixture) + older = {**fixture.page, "properties": {"title": "old"}} + service, fence = source + async with database() as db: + await service.capture( + db, CaptureBatch(fence=fence, records=(connector._record("page", older),)) + ) + await run(instance) + async with database() as db: + page = await db.scalar(select(Entity).where(Entity.native_id == PAGE)) + assert page.source_payload == fixture.page and page.deleted_at is None + assert page.parent_record_type is None + assert any(call[0] == f"pages/{PAGE}" for call in fixture.calls) + + +async def test_known_root_loss_redacts_descendants_even_when_search_returns_no_page( + database, source +): + fixture = NativeFixture() + service, fence = source + instance, connector = await runner(database, source, fixture) + root = connector._record("page", fixture.page) + child = connector._record("block", fixture.blocks[0], parent=root.identity) + async with database() as db: + captured = await service.capture(db, CaptureBatch(fence=fence, records=(root, child))) + + async def missing(url, **kwargs): + path = url.removeprefix("https://api.notion.com/v1/") + if path == "search": + return httpx.Response(200, json=listing(), request=httpx.Request("POST", url)) + assert path == f"pages/{PAGE}" + return httpx.Response( + 404, json={"code": "object_not_found"}, request=httpx.Request("GET", url) + ) + + connector.http_client.get.side_effect = missing + connector.http_client.post.side_effect = missing + await run(instance) + async with database() as db: + for change in captured.changes: + record = await service.store.read( + db, fence.organization_id, fence.sync_id, change.record.id + ) + assert record.content_access == "unavailable" and record.payload == {} + rows = (await db.scalars(select(Entity))).all() + assert all(row.removal_reason == "scope_removed" for row in rows) + + +async def test_prior_block_deleted_between_sweeps_reconciles_after_exact_confirmation( + database, source +): + fixture = NativeFixture() + fixture.interrupt = False + instance, connector = await runner(database, source, fixture) + service, fence = source + root = connector._record("page", fixture.page) + child = connector._record("block", fixture.blocks[0], parent=root.identity) + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(root, child))) + calls = [] + + async def request(url, **kwargs): + path = url.removeprefix("https://api.notion.com/v1/") + calls.append(path) + if path == f"blocks/{PAGE}/children": + return httpx.Response(200, json=listing(), request=httpx.Request("GET", url)) + if path == f"blocks/{BLOCK1}": + return httpx.Response( + 404, json={"code": "object_not_found"}, request=httpx.Request("GET", url) + ) + return await fixture.request(url, **kwargs) + + connector.http_client.get.side_effect = request + await run(instance) + rows, checkpoint, _ = await saved(database) + missing = next(row for row in rows if row.native_id == BLOCK1) + assert missing.deleted_at is not None + assert f"blocks/{BLOCK1}" in calls + assert checkpoint["canonical_cycle"]["phase"] == "complete" + + +class PropertyFixture(NativeFixture): + def __init__(self): + super().__init__() + self.page["properties"] = { + name: {"id": name, "type": "title", "title": []} for name in ("a", "b") + } + self.property_calls = [] + self.interrupt_property = True + + async def request(self, url, *, headers, json=None, params=None): + path = url.removeprefix("https://api.notion.com/v1/") + if path == f"blocks/{PAGE}/children": + return httpx.Response(200, json=listing(), request=httpx.Request("GET", url)) + if "/properties/" in path: + prop = path.rsplit("/", 1)[-1] + cursor = (params or {}).get("start_cursor") + self.property_calls.append((prop, cursor)) + if prop == "b" and cursor and self.interrupt_property: + raise ConnectionError("synthetic interruption within one property") + more = prop == "b" and cursor is None + payload = { + **listing( + [ + { + "object": "property_item", + "id": prop, + "type": "title", + "title": {"plain_text": "retained"}, + } + ], + "following" if more else None, + ), + "type": "property_item", + "property_item": {"id": prop, "type": "title", "next_url": None}, + } + return httpx.Response(200, json=payload, request=httpx.Request("GET", url)) + return await super().request(url, headers=headers, json=json, params=params) + + +async def test_property_retry_keeps_committed_sibling_and_restarts_only_interrupted_property( + database, source, tmp_path, monkeypatch +): + import json + + from airweave.adapters.storage.filesystem import FilesystemBackend + from airweave.domains.storage.file_service import FileService + + service, fence = source + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + files = FileService( + fence.job_id, FilesystemBackend(tmp_path / "storage"), sync_id=fence.sync_id + ) + fixture = PropertyFixture() + first, _ = await runner(database, source, fixture, files=files) + with pytest.raises(ConnectionError, match="within one property"): + await run(first) + rows, checkpoint, scans = await saved(database) + properties = [row for row in rows if row.entity_definition_short_name == "page_property"] + assert [row.native_id for row in properties] == ["a"] + assert "canonical_checkpoint" not in checkpoint + scan = next(row for row in scans if row.record_type == "page_property") + assert scan.continuation["index"] == 1 + assert len(await files.storage.list_files()) == 1 + before = len(fixture.property_calls) + fixture.interrupt_property = False + second, _ = await runner(database, source, fixture, attempt=2, files=files) + await run(second) + assert fixture.property_calls[before:] == [("b", None), ("b", "following")] + rows, checkpoint, _ = await saved(database) + properties = [row for row in rows if row.entity_definition_short_name == "page_property"] + assert {row.native_id for row in properties} == {"a", "b"} + assert all(row.parent_record_type == "page" and row.container_id == PAGE for row in properties) + assert next(row for row in properties if row.native_id == "a").record_revision == 1 + second_property = next(row for row in properties if row.native_id == "b") + archive = json.loads(await files.storage.read_file(second_property.blob_references[0]["key"])) + assert len(archive["responses"]) == 2 + assert checkpoint["canonical_cycle"]["phase"] == "complete" + + # A genuinely later job gets a new cycle and reconciles a removed property. + from airweave.models.sync_job import SyncJob + + previous_cycle = checkpoint["canonical_cycle"]["version"]["cycle_id"] + next_job, next_attempt = uuid4(), uuid4() + async with database() as db: + old_job = await db.get(SyncJob, fence.job_id) + old_job.status = "completed" + db.add( + SyncJob( + id=next_job, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + status="running", + ) + ) + await db.commit() + fixture.page["properties"].pop("a") + fixture.page["last_edited_time"] = "2026-09-30T02:00:00Z" + next_source = ( + service, + fence.model_copy(update={"job_id": next_job, "attempt_id": next_attempt}), + ) + third, _ = await runner(database, next_source, fixture, files=files) + await run(third) + rows, checkpoint, _ = await saved(database) + removed = next( + row + for row in rows + if row.entity_definition_short_name == "page_property" and row.native_id == "a" + ) + assert removed.deleted_at is not None and removed.removal_reason == "absent" + assert checkpoint["canonical_cycle"]["version"]["cycle_id"] != previous_cycle + assert checkpoint["canonical_cycle"]["phase"] == "complete" + + +async def test_removed_native_property_reconciles_only_after_current_inventory(database, source): + from datetime import datetime, timezone + + from airweave.domains.entities.canonical.requests import CaptureRecord, RecordIdentity + + fixture = NativeFixture() + fixture.interrupt = False + instance, connector = await runner(database, source, fixture) + service, fence = source + root = connector._record("page", fixture.page) + old_property = CaptureRecord( + identity=RecordIdentity( + record_type="page_property", native_id="removed", container_id=PAGE + ), + parent=root.identity, + payload={"native": "previously captured value"}, + observed_at=datetime.now(timezone.utc), + completeness="partial", + ) + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=(root, old_property))) + await run(instance) + rows, checkpoint, _ = await saved(database) + removed = next(row for row in rows if row.native_id == "removed") + assert removed.deleted_at is not None and removed.removal_reason == "absent" + assert next(row for row in rows if row.native_id == PAGE).deleted_at is None + assert checkpoint["canonical_cycle"]["phase"] == "complete" diff --git a/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_projection.py b/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_projection.py new file mode 100644 index 0000000000..fb85ed93df --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_projection.py @@ -0,0 +1,227 @@ +"""Offline Graph event projection uses shared conversion and honest omitted-part coverage.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from airweave.domains.converters.html import HtmlConverter +from airweave.domains.entities.canonical.outlook_calendar_projection import map_outlook_calendar +from airweave.domains.entities.canonical.projection_models import ProjectionBinding, ProjectionWork +from airweave.domains.entities.canonical.projector import StrictProjectionTracker, _select_inputs +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.test_outlook_projection import source +from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder + + +def event(body): + record, _ = source(b"") + parent = RecordIdentity(record_type="calendar", native_id="calendar-1") + return record.model_copy( + update={ + "identity": RecordIdentity( + record_type="event", native_id="event-1", container_id="calendar-1" + ), + "parent": parent, + "blobs": (), + "payload": { + "id": "event-1", + "changeKey": "version-1", + "type": "seriesMaster", + "start": { + "dateTime": "2026-11-01T01:30:00.0000000", + "timeZone": "Pacific Standard Time", + }, + "end": { + "dateTime": "2026-11-01T02:30:00.0000000", + "timeZone": "Pacific Standard Time", + }, + "originalStartTimeZone": "tzone://Microsoft/Custom", + "isAllDay": False, + "isCancelled": True, + "hasAttachments": False, + "subject": "Réunion बैठक", + "body": body, + "bodyPreview": "LOSSY PREVIEW MUST NOT APPEAR", + "organizer": {"emailAddress": {"name": "रोहन", "address": "r@example.com"}}, + "attendees": [ + { + "emailAddress": {"name": "Éva", "address": "e@example.com"}, + "type": "required", + "status": {"response": "accepted"}, + "unknown": "DO NOT INDEX", + } + ], + "recurrence": { + "pattern": { + "type": "weekly", + "interval": 2, + "daysOfWeek": ["monday"], + "extension": "DO NOT INDEX", + }, + "range": { + "type": "noEnd", + "startDate": "2026-10-01", + "recurrenceTimeZone": "Pacific Standard Time", + }, + }, + "location": {"displayName": "Office"}, + "categories": ["Planning"], + "nativeUnknown": {"retained": "DO NOT INDEX"}, + }, + } + ) + + +async def build(record, tmp_path): + mapped = await map_outlook_calendar(record, tmp_path) + work = ProjectionWork( + binding=ProjectionBinding( + source_connection_id=uuid4(), source_name="outlook_calendar", collection_id=uuid4() + ), + organization_id=uuid4(), + record=record, + pipeline_version=2, + previous_generation=None, + ) + selected, coverage = _select_inputs(mapped, work, "outlook_calendar", uuid4(), lambda _: True) + registry = MagicMock() + registry.for_extension.return_value = HtmlConverter() + batch = await TextualRepresentationBuilder(registry).build_with_text( + selected, + SimpleNamespace(source_short_name="outlook_calendar", logger=MagicMock()), + SimpleNamespace(entity_tracker=StrictProjectionTracker()), + native_bodies={ + item.entity.entity_id: item.native_body + for item in mapped.parts + if item.entity is not None and item.native_body is not None + }, + ) + return mapped, coverage, batch + + +@pytest.mark.parametrize( + "body_type,text", + [ + ("text", "नमस्ते café\n & exact"), + ("text", ""), + ("html", "

नमस्ते café & body

"), + ], +) +async def test_body_provenance_unicode_schedule_and_partial_inventory( + tmp_path, monkeypatch, body_type, text +): + # Even an accidental future native HTTP request would fail this offline test. + monkeypatch.setattr( + "httpx.AsyncClient.request", AsyncMock(side_effect=AssertionError("offline")) + ) + record = event({"contentType": body_type, "content": text}) + before = record.model_dump() + mapped, coverage, batch = await build(record, tmp_path) + built = batch.representations[0] + assert built.kind == ("native_text" if body_type == "text" else "extracted_text") + assert built.text == batch.entities[0].textual_representation + body = built.text[built.content_start :] + if body_type == "text": + assert body == text + else: + assert "नमस्ते" in body and "café" in body and "&" in body + assert "

" not in body and "" not in body + assert mapped.parts[0].native_body is None + assert "2026-11-01T01:30:00.0000000 (Pacific Standard Time)" in built.text + assert "01:30:00+00:00" not in built.text + assert "tzone://Microsoft/Custom" in built.text + assert "**Is Cancelled**: True" in built.text + assert all(value in built.text for value in ("Éva", "accepted", "weekly", "monday", "Office")) + assert "DO NOT INDEX" not in built.text and "LOSSY PREVIEW" not in built.text + assert coverage.status == "partial" + assert [(part.key, part.outcome) for part in coverage.parts[1:]] == [ + ("/attachment_inventory", "unavailable_original"), + ("/cancelledOccurrences_inventory", "unavailable_original"), + ] + assert record.model_dump() == before + + +async def test_missing_body_is_not_preview_and_calendar_unknown_permissions_stay_unknown(tmp_path): + record = event(None) + _, coverage, batch = await build(record, tmp_path) + assert batch.representations[0].kind == "generated_text" + assert any( + part.key == "/body/content" and part.outcome == "unavailable_original" + for part in coverage.parts + ) + calendar = record.model_copy( + update={ + "identity": record.parent, + "parent": None, + "completeness": "complete", + "payload": { + "id": "calendar-1", + "name": "Équipe", + "owner": {"name": "Owner", "address": "o@x.test"}, + "unknown": "DO NOT INDEX", + }, + } + ) + mapped = await map_outlook_calendar(calendar, tmp_path) + assert mapped.entities[0].owner == "Owner " + assert mapped.entities[0].can_edit is None + assert len(mapped.parts) == 1 + + +@pytest.mark.parametrize( + "changes", + [ + {"parent": None}, + {"parent": RecordIdentity(record_type="calendar", native_id="wrong")}, + { + "parent": RecordIdentity( + record_type="calendar", native_id="calendar-1", container_id="nested" + ) + }, + { + "identity": RecordIdentity( + record_type="event", native_id="wrong", container_id="calendar-1" + ) + }, + {"completeness": "complete"}, + ], +) +async def test_identity_parent_and_unsupported_completeness_fail_before_projection( + tmp_path, changes +): + with pytest.raises(ValueError): + await map_outlook_calendar(event(None).model_copy(update=changes), tmp_path) + + +async def test_empty_html_does_not_publish_fabricated_native_body(tmp_path): + # The shared converter cannot produce retained content for empty HTML. + # Preserve its fail-closed behavior instead of declaring generated text to be the body. + with pytest.raises(ValueError, match="Required projection input could not be converted"): + await build(event({"contentType": "html", "content": ""}), tmp_path) + + +@pytest.mark.parametrize( + "body_type,web_link", + [ + ("text", "https://outlook.example/event/1"), + ("html", "https://outlook.example/event/1"), + ("text", None), + ], +) +async def test_native_web_link_survives_vespa_payload_and_search_result( + tmp_path, body_type, web_link +): + from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB + from airweave.platform.destinations.vespa.transformer import EntityTransformer + + record = event({"contentType": body_type, "content": "Native body"}) + record = record.model_copy(update={"payload": {**record.payload, "webLink": web_link}}) + mapped = await map_outlook_calendar(record, tmp_path) + fields = {"entity_id": "test-event", "name": "Event", "textual_representation": "body"} + EntityTransformer(logger=MagicMock())._add_payload_field(fields, mapped.entities[0]) + engine = VespaVectorDB(app=MagicMock(), logger=MagicMock(), filter_translator=MagicMock()) + result = engine._convert_hits_to_results([{"fields": fields}]).results[0] + assert result.web_url == (web_link or "") + assert mapped.entities[0].web_url == web_link diff --git a/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_recovery.py new file mode 100644 index 0000000000..3a3d5f9a8b --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_outlook_calendar_recovery.py @@ -0,0 +1,241 @@ +"""Actual composed calendar source and SQL with synthetic Graph HTTP only.""" + +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.store import source_record +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.entities.canonical.tests.test_mixed_scopes import next_job +from airweave.domains.sources.exceptions import SourceEntityNotFoundError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.platform.configs.config import CalendarOccurrenceWindow, OutlookCalendarConfig +from airweave.platform.sources.outlook_calendar import OutlookCalendarSource + + +class CalendarHTTP: + def __init__(self): + self.round = 1 + self.interrupt = False + self.missing = False + self.calls = [] + + async def handle(self, request): + path = request.url.path + if path == "/v1.0/me": + return httpx.Response(200, json={"id": "mailbox"}) + assert request.headers["Prefer"] == 'IdType="ImmutableId"' + self.calls.append((path, dict(request.url.params))) + if path == "/v1.0/me/calendars": + return httpx.Response(200, json={"value": [{"id": "cal"}]}) + if path == "/v1.0/me/calendars/cal": + return httpx.Response(200, json={"id": "cal", "canViewPrivateItems": True}) + if path.endswith("/events"): + return httpx.Response(200, json={"value": [{"id": "single"}, {"id": "master"}]}) + if path.endswith("/calendarView"): + if self.interrupt: + raise ConnectionError("Synthetic phase interruption") + ids = ["single", "history" if self.round == 1 else "current"] + return httpx.Response(200, json={"value": [{"id": item} for item in ids]}) + identity = path.rsplit("/", 1)[-1] + assert identity in {"single", "master", "history", "current"} + if identity == "history" and self.missing: + return httpx.Response(404, json={"error": {"code": "ErrorItemNotFound"}}) + kind = {"single": "singleInstance", "master": "seriesMaster"}.get(identity, "occurrence") + day = "2026-03-03" if identity == "history" else "2026-03-08" + return httpx.Response( + 200, + json={ + "id": identity, + "changeKey": "stable", + "type": kind, + "start": {"dateTime": day + "T09:00:00", "timeZone": "UTC"}, + "end": {"dateTime": day + "T10:00:00", "timeZone": "UTC"}, + "isAllDay": False, + "isCancelled": False, + "hasAttachments": False, + "subject": identity, + "body": {"contentType": "text", "content": "Exact native body — नमस्ते"}, + "unknownNativeField": {"retained": True}, + }, + ) + + +async def capture(database, source, native, attempt=1): + service, fence = source + async with httpx.AsyncClient(transport=httpx.MockTransport(native.handle)) as client: + connector = await OutlookCalendarSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=OutlookCalendarConfig( + capture_originals=True, + expected_principal_id="mailbox", + ), + ) + adapter = connector.capture_page_source + assert adapter is not None + ctx, _, runtime, bus = components(database, source) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + adapter.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + adapter.canonical_container_parents, + page_source=adapter, + files=MagicMock(), + ) + await pipeline.start(ctx) + + async def no_limits(): + pass + + await pipeline.run_scans(ctx, runtime, no_limits) + await pipeline.cleanup_orphaned_entities(ctx, runtime) + await pipeline.save_checkpoint(ctx, runtime) + + +async def state(database, source): + async with database() as db: + rows = ( + await db.scalars(select(Entity).where(Entity.entity_definition_short_name == "event")) + ).all() + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) + coverage = await capture_coverage(db, source[1].organization_id, (source[1].sync_id,)) + return {row.native_id: row for row in rows}, scan, coverage[source[1].sync_id] + + +@pytest.mark.parametrize("missing", [False, True]) +async def test_calendar_phase_resume_and_exact_historical_validation( + database, source, missing, monkeypatch +): + native = CalendarHTTP() + clock = {"start": "2026-03-01T00:00:00Z"} + monkeypatch.setattr( + OutlookCalendarConfig, + "resolved_window", + lambda self: CalendarOccurrenceWindow(start=clock["start"], end="2026-04-01T00:00:00Z"), + ) + await capture(database, source, native) + initial, _, coverage = await state(database, source) + assert set(initial) == {"single", "master", "history"} + assert coverage.phase == "complete" and coverage.discovery == "incomplete" + source = await next_job(database, source) + native.round, native.interrupt = 2, True + clock["start"] = "2026-03-06T00:00:00Z" + with pytest.raises(ConnectionError, match="phase interruption"): + await capture(database, source, native) + _, interrupted, coverage = await state(database, source) + assert interrupted.phase == "collecting" + assert interrupted.continuation["phase"] == "calendarView" + assert coverage.phase == "active" + sweep, window = interrupted.sweep_id, interrupted.continuation["context"]["window"] + call_boundary = len(native.calls) + native.interrupt, native.missing = False, missing + clock["start"] = "2026-03-07T00:00:00Z" + if missing: + with pytest.raises(SourceEntityNotFoundError): + await capture(database, source, native, attempt=2) + else: + await capture(database, source, native, attempt=2) + rows, scan, coverage = await state(database, source) + assert scan.sweep_id == sweep + assert scan.continuation["context"]["window"] == window + assert set(rows) == {"single", "master", "history", "current"} + assert all(row.deleted_at is None for row in rows.values()) + assert rows["single"].id == initial["single"].id + assert rows["single"].record_revision == initial["single"].record_revision + assert all(row.parent_native_id == "cal" and row.container_id == "cal" for row in rows.values()) + assert rows["history"].id == initial["history"].id + assert rows["single"].source_payload["unknownNativeField"] == {"retained": True} + resumed = native.calls[call_boundary:] + assert resumed[0][0] == "/v1.0/me/calendars" + assert not any(path.endswith("/events") for path, _ in resumed) + assert any(path.endswith("/events/history") for path, _ in resumed) + view = next(params for path, params in resumed if path.endswith("/calendarView")) + assert view["startDateTime"].startswith("2026-03-06") + assert coverage.phase == ("active" if missing else "complete") + assert scan.phase == ("reconciling" if missing else "complete") + if not missing: + async with map_record( + source_record(rows["single"]), "outlook_calendar", MagicMock() + ) as mapped: + assert any( + part.native_body and part.native_body.text == "Exact native body — नमस्ते" + for part in mapped.parts + ) + assert any(part.entity is None for part in mapped.parts) + assert scan.execution_state["published"]["request_context"]["window"] == window + assert scan.execution_state["published"]["checkpoint"] is None + assert all(row.last_seen_run_id == sweep for row in rows.values()) + + +@pytest.mark.parametrize("mode,checkpoint", [("changes", None), ("full", {"token": "unsafe"})]) +async def test_full_scope_plan_rejects_incremental_authority(database, source, mode, checkpoint): + from airweave.domains.entities.canonical.cycle_models import ProviderCheckpoint + from airweave.domains.entities.canonical.scan_store import ScanConflict + from airweave.domains.entities.canonical.scope_execution import ScopePlan + from airweave.domains.entities.canonical.tests.test_scans import begin + + with pytest.raises(ScanConflict, match="cannot request incremental"): + await begin( + database, + *source, + plan=ScopePlan( + mode=mode, + starting_checkpoint=ProviderCheckpoint(value=checkpoint) if checkpoint else None, + ), + ) + async with database() as db: + assert await db.scalar(select(CaptureScan)) is None + + +async def test_full_scope_plan_rejects_parent_changed_during_planning( + database, source, monkeypatch +): + from datetime import datetime, timezone + + from airweave.domains.entities.canonical.requests import CaptureRecord + from airweave.domains.entities.canonical.scan_store import ScanConflict + from airweave.domains.entities.canonical.tests.helpers import capture as capture_records + from airweave.platform.sources.outlook_calendar_capture import OutlookCalendarCapture + + original = OutlookCalendarCapture.prepare_scope + + async def race(self, scope, cycle, previous, *, parent, force_full): + plan = await original(self, scope, cycle, previous, parent=parent, force_full=force_full) + if parent is not None: + await capture_records( + database, + *source, + CaptureRecord( + identity=parent.identity, + payload={**parent.payload, "name": "Renamed while selecting plan"}, + observed_at=datetime.now(timezone.utc), + ), + ) + return plan + + monkeypatch.setattr(OutlookCalendarCapture, "prepare_scope", race) + native = CalendarHTTP() + with pytest.raises(ScanConflict, match="owner changed during plan selection"): + await capture(database, source, native) + async with database() as db: + assert ( + await db.scalar(select(Entity).where(Entity.entity_definition_short_name == "event")) + is None + ) + assert ( + await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "event")) is None + ) + assert not any(path.endswith("/events") for path, _ in native.calls) diff --git a/backend/airweave/domains/entities/canonical/tests/test_outlook_projection.py b/backend/airweave/domains/entities/canonical/tests/test_outlook_projection.py new file mode 100644 index 0000000000..5319173d49 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_outlook_projection.py @@ -0,0 +1,222 @@ +"""Retained MIME replay: no snippet substitution, bytes loss, or invented attachment identity.""" + +import hashlib +from datetime import datetime, timezone +from email.message import EmailMessage +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.outlook_projection import map_outlook +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity + + +def source(content: bytes): + sync_id = uuid4() + digest = hashlib.sha256(content).hexdigest() + record = SourceRecord( + id=uuid4(), + sync_id=sync_id, + identity=RecordIdentity(record_type="message", native_id="immutable-1"), + revision=1, + payload={ + "id": "immutable-1", + "changeKey": "change-1", + "parentFolderId": "folder-1", + "subject": "Meeting", + "conversationId": "conversation-1", + "bodyPreview": "lossy-preview", + "sender": {"emailAddress": {"name": "रोहन", "address": "assistant@example.com"}}, + "from": {"emailAddress": {"address": "author@example.com"}}, + "toRecipients": [ + {"emailAddress": {"name": "Doe, Jane", "address": "jane@example.com"}} + ], + "bccRecipients": [{"emailAddress": {"address": "bcc@example.com"}}], + "replyTo": [{"emailAddress": {"address": "reply@example.com"}}], + "receivedDateTime": "2026-10-01T01:02:03Z", + "nativeUnknown": {"retained": True}, + }, + payload_schema_version=1, + capture_hash="capture", + content_hash=None, + completeness="partial", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=( + BlobReference( + key=f"canonical/{sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="message/rfc822", + ), + ), + indexed_revision=None, + indexed_pipeline_version=None, + ) + storage = AsyncMock() + storage.read_file.return_value = content + return record, storage + + +@pytest.mark.asyncio +async def test_mixed_related_alternative_preserves_unicode_metadata_and_attachments(tmp_path): + message = EmailMessage() + message.set_content("duplicate plain body") + message.add_alternative("

नमस्ते café, पूरा पाठ

", subtype="html", charset="utf-8") + message.get_payload()[1].add_related( + b"image-original", maintype="image", subtype="png", cid="", filename="../../same.png" + ) + message.add_attachment( + b"pdf-original", maintype="application", subtype="pdf", filename="../../same.png" + ) + record, storage = source(message.as_bytes()) + before = record.model_dump() + result = await map_outlook(record, storage, tmp_path) + body, image, attachment = result.entities + assert "नमस्ते café, पूरा पाठ" in Path(body.local_path).read_text() + assert "duplicate plain body" not in Path(body.local_path).read_text() + assert "lossy-preview" not in Path(body.local_path).read_text() + assert body.sender == '"रोहन" ' + assert body.from_address == "author@example.com" + assert body.to_recipients == ['"Doe, Jane" '] + assert body.bcc_recipients == ["bcc@example.com"] and body.reply_to == ["reply@example.com"] + assert body.conversation_id == "conversation-1" and body.folder_id == "folder-1" + assert body.folder_name is None + assert image.is_inline and image.content_id == "" + assert attachment.file_type == "pdf" and image.file_type == "png" + assert image.composite_id != attachment.composite_id + assert [part.part.key for part in result.parts[1:-1]] == [ + "/mime/parts/0/parts/1/parts/1", + "/mime/parts/1", + ] + assert {Path(entity.local_path).read_bytes() for entity in result.entities[1:]} == { + b"image-original", + b"pdf-original", + } + assert all(Path(entity.local_path).parent == tmp_path for entity in result.entities) + assert result.parts[-1].part.key == "/attachment_inventory" and result.parts[-1].entity is None + assert record.model_dump() == before + storage.read_file.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_mislabeled_utf8_mime_retains_original_and_discloses_recovery(tmp_path): + text = "नमस्ते — café" + raw = ( + b"Content-Type: text/plain; charset=gb2312\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + + text.encode() + ) + record, storage = source(raw) + before = record.model_dump() + result = await map_outlook(record, storage, tmp_path) + assert text in Path(result.entities[0].local_path).read_text() + recovery = result.parts[0].part.charset_recoveries[0] + assert recovery.source_path == "/mime" and recovery.from_charset == "gb2312" + assert record.model_dump() == before + assert storage.read_file.return_value == raw + + +@pytest.mark.asyncio +async def test_latin_charset_and_explicit_related_root_are_respected(tmp_path): + message = EmailMessage() + message.set_content("café ", charset="iso-8859-1") + record, storage = source(message.as_bytes()) + record.payload["subject"] = " " + result = await map_outlook(record, storage, tmp_path) + assert result.entities[0].subject == "Email message" + assert "café <full text>" in Path(result.entities[0].local_path).read_text() + message = EmailMessage() + message.make_related() + message.set_param("start", "") + image = EmailMessage() + image.set_content(b"image", maintype="image", subtype="png") + image["Content-ID"] = "" + body = EmailMessage() + body.set_content("selected body") + body["Content-ID"] = "" + message.attach(image) + message.attach(body) + record, storage = source(message.as_bytes()) + result = await map_outlook(record, storage, tmp_path) + assert "selected body" in Path(result.entities[0].local_path).read_text() + assert result.entities[1].content_id == "" + + +@pytest.mark.asyncio +async def test_missing_and_unsupported_originals_remain_explicit(tmp_path): + record, storage = source(b"Subject: empty\r\n\r\n") + omitted = record.model_copy(update={"blobs": (), "completeness": "metadata_only"}) + result = await map_outlook(omitted, storage, tmp_path) + assert not result.entities and result.parts[0].entity is None + storage.read_file.assert_not_called() + with pytest.raises(ValueError, match="metadata-only"): + await map_outlook(record.model_copy(update={"blobs": ()}), storage, tmp_path) + message = EmailMessage() + message.set_content("retained body") + nested = EmailMessage() + nested.set_content("nested mail is not silently indexed") + message.add_attachment(nested) + record, storage = source(message.as_bytes()) + result = await map_outlook(record, storage, tmp_path) + assert len(result.entities) == 1 + assert result.parts[1].omission == "unsupported_format" + assert result.parts[1].part.media_type == "message/rfc822" + encrypted = b"MIME-Version: 1.0\r\nContent-Type: application/pkcs7-mime\r\n\r\nencrypted" + record, storage = source(encrypted) + result = await map_outlook(record, storage, tmp_path) + assert not result.entities and result.parts[0].omission == "unsupported_format" + + +@pytest.mark.asyncio +async def test_identity_and_blob_integrity_fail_before_materialization(tmp_path): + record, storage = source(b"Subject: retained\r\n\r\nbody") + bad = record.model_copy( + update={"identity": RecordIdentity(record_type="message", native_id="different")} + ) + with pytest.raises(ValueError, match="identity"): + await map_outlook(bad, storage, tmp_path) + storage.read_file.assert_not_called() + bad_ref = record.blobs[0].model_copy(update={"key": "canonical/other/blobs/sha256/hash"}) + with pytest.raises(ValueError, match="source scope"): + await map_outlook(record.model_copy(update={"blobs": (bad_ref,)}), storage, tmp_path) + storage.read_file.assert_not_called() + storage.read_file.return_value = b"bad bytes" + with pytest.raises(ValueError, match="digest"): + await map_outlook(record, storage, tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.asyncio +async def test_malformed_transfers_charset_and_structure_are_never_repaired_silently(tmp_path): + invalid_messages = [ + b"Content-Transfer-Encoding: base64\r\n\r\naGVsbG8=@@", + b"Content-Transfer-Encoding: quoted-printable\r\n\r\nhello=ZZ", + b"Content-Type: text/plain; charset=utf-8\r\n\r\n\xff", + b"Content-Type: multipart/mixed; boundary=missing\r\n\r\nno boundary", + ] + for content in invalid_messages: + record, storage = source(content) + with pytest.raises(ValueError): + await map_outlook(record, storage, tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.asyncio +async def test_pathological_nesting_is_explicit_failure_not_partial_text(tmp_path): + message = EmailMessage() + message.set_content("body") + for _ in range(34): + parent = EmailMessage() + parent.make_mixed() + parent.attach(message) + message = parent + record, storage = source(message.as_bytes()) + with pytest.raises(ValueError, match="depth or part limit"): + await map_outlook(record, storage, tmp_path) + assert not list(tmp_path.iterdir()) diff --git a/backend/airweave/domains/entities/canonical/tests/test_outlook_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_outlook_recovery.py new file mode 100644 index 0000000000..424aef19f5 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_outlook_recovery.py @@ -0,0 +1,332 @@ +"""Outlook native HTTP fixtures through real canonical SQL and retained blob storage.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.entities.canonical.blob_materializer import read_blob +from airweave.domains.entities.canonical.cycle_models import CompleteCycle, TerminalCheckpoint +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.file_service import FileService +from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob +from airweave.platform.configs.config import OutlookMailConfig +from airweave.platform.sources.outlook_graph import OutlookGraphClient +from airweave.platform.sources.outlook_mail_capture import OutlookMailCapture + +BASE = "https://graph.microsoft.com/v1.0/me" +MIME = ( + b"Subject: Original\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n" + b"Original retained bytes\r\n" +) + + +class Mailbox: + """One interrupted list page, followed by a move of the same immutable message.""" + + def __init__(self): + self.fail_next_page = True + self.calls = [] + self.moved = False + self.incremental = False + self.fail_incremental = True + + def incremental_page(self, request): + """One new delta round interrupted after the first folder.""" + assert request.url.params.get("$deltatoken") == "one" + if "/sent/" in request.url.path and self.fail_incremental: + raise httpx.ConnectError("Synthetic incremental interruption", request=request) + return httpx.Response( + 200, + json={ + "value": [{"id": "a", "@removed": {"reason": "deleted"}}], + "@odata.deltaLink": str(request.url.copy_with(query=b"$deltatoken=two")), + }, + ) + + async def handle(self, request): + path = request.url.path + if path == "/v1.0/me": + return httpx.Response(200, json={"id": "mailbox"}) + assert request.headers["Prefer"] == 'IdType="ImmutableId"' + self.calls.append((path, str(request.url.query))) + if path == "/v1.0/me/mailFolders": + return httpx.Response(200, json={"value": [{"id": "inbox"}, {"id": "sent"}]}) + if path.endswith("/childFolders"): + return httpx.Response(200, json={"value": []}) + if self.incremental and path.endswith("/messages/delta"): + return self.incremental_page(request) + if path == "/v1.0/me/mailFolders/sent/messages/delta": + return httpx.Response( + 200, + json={ + "value": [], + "@odata.deltaLink": BASE + "/mailFolders/sent/messages/delta?$deltatoken=one", + }, + ) + if path == "/v1.0/me/mailFolders/inbox/messages/delta": + if request.url.params.get("$skiptoken") == "second": + if self.fail_next_page: + raise httpx.ConnectError("Synthetic page interruption", request=request) + self.moved = True + return httpx.Response( + 200, + json={ + "value": [{"id": "a"}, {"id": "b"}], + "@odata.deltaLink": BASE + + "/mailFolders/inbox/messages/delta?$deltatoken=one", + }, + ) + return httpx.Response( + 200, + json={ + "value": [{"id": "a"}], + "@odata.nextLink": BASE + "/mailFolders/inbox/messages/delta?$skiptoken=second", + }, + ) + if path.endswith("/$value"): + return httpx.Response(200, content=MIME, headers={"Content-Type": "message/rfc822"}) + identity = path.rsplit("/", 1)[-1] + assert identity in {"a", "b"} + return httpx.Response( + 200, + json={ + "id": identity, + "changeKey": "returned" + if self.incremental + else ("moved" if self.moved and identity == "a" else "original"), + "parentFolderId": "sent" + if self.moved and identity == "a" and not self.incremental + else "inbox", + "subject": "Original", + "body": {"contentType": "text", "content": "Original retained bytes"}, + "createdDateTime": "2026-09-01T00:00:00Z", + "lastModifiedDateTime": "2026-09-02T00:00:00Z", + "unknownNativeField": {"retained": True}, + }, + ) + + +async def run_capture(database, service, fence, mailbox, storage): + async with httpx.AsyncClient(transport=httpx.MockTransport(mailbox.handle)) as client: + graph = OutlookGraphClient( + StaticTokenProvider("synthetic"), client, "outlook_mail", "mailbox" + ) + connector = await OutlookMailCapture.create( + graph=graph, + config=OutlookMailConfig( + expected_principal_id="mailbox", included_folders=[], excluded_folders=[] + ), + ) + files = FileService(fence.job_id, storage, sync_id=fence.sync_id) + try: + return await CanonicalScanDriver( + service, database, fence, connector, AsyncMock(), AsyncMock(), files + ).run() + finally: + await files.cleanup_sync_directory(MagicMock()) + + +async def complete(database, service, fence, cycle): + """Match production terminal publication using the exact committed root scan.""" + async with database() as db: + scan = await service.read_scan(db, fence, CompletedScope(record_type="message")) + return await service.complete_cycle( + db, + CompleteCycle( + fence=fence, + expected=cycle.version, + terminal_checkpoint=TerminalCheckpoint( + record_type="message", + expected=scan.version, + checkpoint=scan.provider_checkpoint, + ), + ), + ) + + +async def test_resumed_mailbox_page_preserves_original_and_updates_move_in_place( + database, source, tmp_path +): + service, fence = source + mailbox = Mailbox() + storage = FilesystemBackend(tmp_path) + with pytest.raises(httpx.RequestError): + await run_capture(database, service, fence, mailbox, storage) + async with database() as db: + rows = list((await db.scalars(select(Entity))).all()) + assert len(rows) == 1 and rows[0].native_id == "a" and rows[0].deleted_at is None + initial_id = rows[0].id + initial_revision = rows[0].record_revision + scan = await db.scalar(select(CaptureScan)) + assert scan.phase == "collecting" + before = await service.read_cycle(db, fence) + renewed = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + mailbox.fail_next_page = False + split = len(mailbox.calls) + after = await run_capture(database, service, renewed, mailbox, storage) + after = await complete(database, service, renewed, after) + assert after.phase == "complete" + assert after.last_full_capture.discovery == "incomplete" + assert after.promoted_checkpoint is not None + assert after.version.cycle_id == before.version.cycle_id + listing = [ + query + for path, query in mailbox.calls[split:] + if path == "/v1.0/me/mailFolders/inbox/messages/delta" + ] + assert len(listing) == 1 and "second" in listing[0] + async with database() as db: + rows = list((await db.scalars(select(Entity))).all()) + assert len(rows) == 2 and all(row.deleted_at is None for row in rows) + moved = next(row for row in rows if row.native_id == "a") + assert moved.id == initial_id and moved.record_revision == initial_revision + 1 + assert moved.source_payload["parentFolderId"] == "sent" + assert moved.source_payload["unknownNativeField"] == {"retained": True} + retained = await service.store.read(db, fence.organization_id, fence.sync_id, moved.id) + assert retained.parent is None and retained.identity.container_id is None + assert retained.completeness == "partial" and len(retained.blobs) == 1 + assert await read_blob(retained, retained.blobs[0], storage) == MIME + # A new job consumes the promoted folder tokens, never re-enumerates messages. + async with database() as db: + previous_job = await db.get(SyncJob, renewed.job_id) + previous_job.status = "completed" + job_id = uuid4() + db.add( + SyncJob( + id=job_id, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + status="running", + ) + ) + await db.commit() + next_fence = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + job_id, + attempt_id=uuid4(), + attempt_number=1, + ) + mailbox.incremental = True + with pytest.raises(httpx.RequestError): + await run_capture(database, service, next_fence, mailbox, storage) + async with database() as db: + partial = await service.read_cycle(db, next_fence) + assert partial.mode == "changes" and partial.phase == "active" + assert partial.promoted_checkpoint == after.promoted_checkpoint + rows = list((await db.scalars(select(Entity))).all()) + assert len(rows) == 2 and all(row.deleted_at is None for row in rows) + mailbox.fail_incremental = False + split = len(mailbox.calls) + changed = await run_capture(database, service, next_fence, mailbox, storage) + changed = await complete(database, service, next_fence, changed) + async with database() as db: + rows = list((await db.scalars(select(Entity))).all()) + moved = next(row for row in rows if row.native_id == "a") + assert moved.id == initial_id and moved.deleted_at is None + assert moved.source_payload["parentFolderId"] == "inbox" + retained = await service.store.read(db, fence.organization_id, fence.sync_id, moved.id) + assert await read_blob(retained, retained.blobs[0], storage) == MIME + assert changed.mode == "changes" + assert all( + "two" in value + for value in changed.promoted_checkpoint.checkpoint.value["folder_links"].values() + ) + delta_requests = [path for path, _ in mailbox.calls[split:] if path.endswith("/delta")] + assert delta_requests == ["/v1.0/me/mailFolders/sent/messages/delta"] + # Actual mapper dispatch consumes retained bytes after the provider client has closed. + async with map_record(retained, "outlook_mail", storage) as projected: + assert len(projected.entities) == 1 + assert projected.entities[0].id == "a" + assert projected.parts[-1].part.key == "/attachment_inventory" + assert projected.parts[-1].entity is None + + +@pytest.mark.parametrize( + "provider,original_config", + [ + ("outlook_mail", {"capture_originals": True, "expected_principal_id": "mailbox"}), + ( + "stripe", + { + "original_capture": { + "expected_account_id": "acct_selected", + "livemode": False, + "api_version": "2025-06-30.basil", + } + }, + ), + ], +) +async def test_factory_rejects_mode_conflict_before_auth_or_cursor_reset( + database, source, provider, original_config +): + """A full sync or skip-load cannot reinterpret a persisted Outlook cursor.""" + from airweave.domains.sync_pipeline.config import SyncConfig + from airweave.domains.sync_pipeline.factory import SyncFactory + from airweave.models.sync_cursor import SyncCursor + from airweave.platform.sources.outlook_mail import OutlookMailSource + from airweave.platform.sources.stripe import StripeSource + + _, fence = source + factory = object.__new__(SyncFactory) + factory._source_lifecycle_service = MagicMock(create=AsyncMock()) + factory._source_registry = MagicMock() + factory._source_registry.get.return_value.source_class_ref = ( + OutlookMailSource if provider == "outlook_mail" else StripeSource + ) + factory._build_arf_replay_source = AsyncMock(return_value="offline legacy replay") + ctx = MagicMock() + ctx.organization.id = fence.organization_id + sc = MagicMock(short_name=provider, config_fields={}) + sync = MagicMock(id=fence.sync_id) + job = MagicMock(id=fence.job_id) + replay = SyncConfig.model_validate({"behavior": {"replay_from_arf": True}}) + async with database() as db: + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == fence.sync_id)) + if cursor is None: + cursor = SyncCursor( + sync_id=fence.sync_id, organization_id=fence.organization_id, cursor_data={} + ) + db.add(cursor) + cursor.cursor_data = {} + await db.flush() + assert ( + await factory._build_source(db, sync, job, ctx, MagicMock(), sc, False, replay) + == "offline legacy replay" + ) + factory._build_arf_replay_source.reset_mock() + sc.config_fields = original_config + with pytest.raises(ValueError, match="not mutable ARF"): + await factory._build_source(db, sync, job, ctx, MagicMock(), sc, False, replay) + cursor.cursor_data = {"delta_link": "legacy continuation"} + await db.flush() + for force_full_sync in (False, True): + with pytest.raises(ValueError, match="retained state"): + await factory._build_source( + db, sync, job, ctx, MagicMock(), sc, force_full_sync, replay + ) + sc.config_fields = {} + cursor.cursor_data = {"canonical_cycle": {"phase": "active"}} + await db.flush() + with pytest.raises(ValueError, match="retained state"): + await factory._build_source(db, sync, job, ctx, MagicMock(), sc, True, replay) + factory._source_lifecycle_service.create.assert_not_called() + factory._build_arf_replay_source.assert_not_called() diff --git a/backend/airweave/domains/entities/canonical/tests/test_owned_grouping.py b/backend/airweave/domains/entities/canonical/tests/test_owned_grouping.py new file mode 100644 index 0000000000..3c0aca4c50 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_owned_grouping.py @@ -0,0 +1,138 @@ +"""Real SQL final publication checks precede grouping and result limits.""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from airweave.api import deps +from airweave.core.protocols.reranker import RerankerResult +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import capture, observation, publish_prepared +from airweave.domains.entities.canonical.tests.test_owned_search import ( # noqa: F401 + http_search, + indexed, +) +from airweave.domains.entities.canonical.tests.test_search_visibility import hit +from airweave.domains.native_ingestion.tests.test_ingestion import ingest +from airweave.domains.native_ingestion.tests.test_projection import ( + message as native_message, +) +from airweave.domains.native_ingestion.tests.test_projection import ( + session as native_session, +) +from airweave.domains.search.types import SearchResults +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection + + +@pytest.mark.parametrize("withdraw", [False, True, "parent"]) +async def test_group_after_final_gate_promotes_survivor_and_limits_cards( + database, + source, + indexed, # noqa: F811 + http_search, # noqa: F811 + withdraw, +): + fence, _, connection = indexed + capture_service, _ = source + client, vector, _, _, _ = http_search + messages = [ + observation( + identity=RecordIdentity(record_type="message", native_id=f"m{i}"), + payload={"threadId": "shared" if i < 3 else "other"}, + ) + for i in range(4) + ] + if withdraw == "parent": + session = native_session() + parent = session.identity + original = native_message("Original message") + native_messages = [ + original.model_copy( + update={ + "identity": RecordIdentity( + record_type="message", native_id=f"m{i}", container_id=parent.native_id + ), + "original": original.original | {"id": f"m{i}", "ordinal": i}, + } + ) + for i in range(4) + ] + async with database() as db: + await db.execute( + update(SourceConnection) + .where(SourceConnection.id == connection.id) + .values( + short_name="almanac", + config_fields={"owner_id": "owner-one", "dataset": "sessions"}, + ) + ) + await db.commit() + await ingest(database, fence, session, *native_messages) + else: + await capture(database, capture_service, fence, *messages) + store = CanonicalProjectionStore() + async with database() as db: + collection_id = await db.scalar( + select(Collection.id).where(Collection.readable_id == connection.readable_collection_id) + ) + pending = await store.pending(db, fence.organization_id, fence.sync_id) + candidates = [] + for work in sorted(pending, key=lambda item: item.record.identity.native_id): + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1, collection_id) + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ) + candidate = hit(fence, locator.encode()) + if withdraw == "parent": + candidate.airweave_system_metadata.source_name = "almanac" + candidate.name = work.record.identity.native_id + candidate.textual_representation = "exact " + candidate.name + candidates.append(candidate) + vector.seed_results(SearchResults(results=candidates)) + service = client._transport.app.dependency_overrides[deps.get_container]().owned_search + service._tokenizer = type("Tokenizer", (), {"count_tokens": lambda self, text: len(text)})() + + async def rerank(query, documents, top_n): + if withdraw: + await capture( + database, + capture_service, + fence, + observation( + identity=parent if withdraw == "parent" else messages[0].identity, + kind="delete", + removal_reason="access_revoked", + ), + ) + return [RerankerResult(i, float(top_n - i)) for i in range(top_n)] + + service._reranker = type("Reranker", (), {"rerank": AsyncMock(side_effect=rerank)})() + response = await client.post( + "/sync/search", + json={"query": "exact", "sync_ids": [str(fence.sync_id)], "mode": "keyword", "limit": 2}, + ) + assert response.status_code == 200, response.text + items = response.json()["items"] + if withdraw == "parent": + assert items == [] + return + assert len(items) == 2 + first = items[0] + assert first["identity"]["native_id"] == ("m1" if withdraw else "m0") + assert first["title"] == first["identity"]["native_id"] + assert first["excerpts"] == ["exact " + first["title"]] + assert first["group"]["matched_records"] == (2 if withdraw else 3) + assert first["group"]["native_id"] == "shared" + assert items[1]["group"]["native_id"] == "other" + assert all(m["identity"]["native_id"] != "m0" for m in first["group"]["additional_matches"]) diff --git a/backend/airweave/domains/entities/canonical/tests/test_owned_ranking.py b/backend/airweave/domains/entities/canonical/tests/test_owned_ranking.py new file mode 100644 index 0000000000..ff2dc9758f --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_owned_ranking.py @@ -0,0 +1,111 @@ +"""Real SQL disclosure and final publication fencing around a fake reranker.""" + +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import event + +from airweave.api import deps +from airweave.core.protocols.reranker import RerankerResult +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.entities.canonical.tests.test_owned_search import ( # noqa: F401 + http_search, # noqa: F811 + indexed, # noqa: F811 +) +from airweave.domains.entities.canonical.tests.test_search_visibility import hit +from airweave.domains.search.types import SearchResults + + +class QuarterCharacterTokenizer: + """Fake count sufficient to expose full-chunk versus display-excerpt behavior.""" + + def count_tokens(self, text): + return (len(text) + 3) // 4 + + +@pytest.mark.parametrize("edit_stage", [None, "before", "during", "before_revoke", "during_revoke"]) +async def test_retained_match_disclosure_and_final_gate( + database, + source, + indexed, # noqa: F811 + http_search, # noqa: F811 + edit_stage, +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + capture_service, _ = source + candidate = hit(fence, locator.encode()).model_copy( + update={ + "textual_representation": "x" * 2500 + "MATCH_AFTER_DISPLAY_BOUNDARY", + "raw_source_fields": {"secret": "RAW_MUST_NOT_TRANSFER"}, + } + ) + vector.seed_results(SearchResults(results=[candidate])) + service = client._transport.app.dependency_overrides[deps.get_container]().owned_search + service._tokenizer = QuarterCharacterTokenizer() + checked_out = 0 + + def checkout(*args): + nonlocal checked_out + checked_out += 1 + + def checkin(*args): + nonlocal checked_out + checked_out -= 1 + + async with database() as db: + engine = db.bind.sync_engine + event.listen(engine, "checkout", checkout) + event.listen(engine, "checkin", checkin) + + async def edit(): + changed = ( + observation(kind="delete", removal_reason="access_revoked") + if edit_stage and edit_stage.endswith("revoke") + else observation(payload={"changed": True}) + ) + await capture(database, capture_service, fence, changed) + + if edit_stage in {"before", "before_revoke"}: + enrich = service._enrich + + async def enrich_then_edit(*args): + result = await enrich(*args) + await edit() + return result + + service._enrich = enrich_then_edit + + async def rerank(query, documents, top_n): + assert checked_out == 0 + assert "MATCH_AFTER_DISPLAY_BOUNDARY" in documents[0] + assert "RAW_MUST_NOT_TRANSFER" not in documents[0] + if edit_stage in {"during", "during_revoke"}: + await edit() + return [RerankerResult(index=0, relevance_score=0.9)] + + service._reranker = type("Port", (), {})() + service._reranker.rerank = AsyncMock(side_effect=rerank) + try: + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + }, + ) + finally: + event.remove(engine, "checkout", checkout) + event.remove(engine, "checkin", checkin) + assert response.status_code == 200, response.text + body = response.json() + assert len(body["items"]) == int(edit_stage is None) + assert service._reranker.rerank.await_count == int( + edit_stage not in {"before", "before_revoke"} + ) + if edit_stage is None: + assert body["ranking"]["method"] == "shared_rerank" + assert "MATCH_AFTER_DISPLAY_BOUNDARY" not in body["items"][0]["excerpts"][0] + else: + assert body["excluded_candidates"] == 1 diff --git a/backend/airweave/domains/entities/canonical/tests/test_owned_search.py b/backend/airweave/domains/entities/canonical/tests/test_owned_search.py new file mode 100644 index 0000000000..f8e18acf56 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_owned_search.py @@ -0,0 +1,674 @@ +"""Real SQL authority and actual HTTP/executor with fake search/embedding transport.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event, select, update + +from airweave.api import deps +from airweave.api.v1.endpoints.records import router +from airweave.db.session import get_db +from airweave.domains.embedders.fakes.embedder import FakeDenseEmbedder, FakeSparseEmbedder +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.tests.helpers import capture, observation, publish_prepared +from airweave.domains.entities.canonical.tests.test_search_visibility import hit +from airweave.domains.search.adapters.vector_db.fakes.vector_db import FakeVectorDB +from airweave.domains.search.executor import SearchPlanExecutor +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.search.types import SearchResults +from airweave.models.collection import Collection +from airweave.models.entity import Entity +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +@pytest.fixture +async def indexed(database, source): + service, fence = source + await capture(database, service, fence, observation()) + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="fake", embedding_dimensions=3, sparse_embedder="fake" + ) + db.add(deployment) + await db.flush() + collection = Collection( + name="Test", + readable_id="owned-search", + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + db.add(collection) + await db.flush() + connection = SourceConnection( + name="Test", + short_name="gmail", + organization_id=fence.organization_id, + readable_collection_id=collection.readable_id, + sync_id=fence.sync_id, + is_authenticated=True, + ) + db.add(connection) + await db.commit() + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(index_pipeline_version=2) + ) + await db.commit() + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1, collection.id) + locator = ProjectionLocator( + record_id=work.record.id, + revision=1, + pipeline_version=2, + generation=generation, + part_index=0, + ) + return fence, locator, connection + + +@pytest.fixture +async def http_search(database, indexed): + fence, locator, connection = indexed + registry = SimpleNamespace( + get=lambda _: SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("event", "message")) + ) + ) + vector = FakeVectorDB() + dense = AsyncMock() + dense.embed_many.side_effect = AssertionError("Keyword search must not call dense embedding") + # Any accidental federation discovery fails instead of quietly returning empty. + providers = MagicMock() + providers.get_all_by_collection = AsyncMock(side_effect=AssertionError("No provider discovery")) + executor = SearchPlanExecutor( + dense_embedder=dense, + sparse_embedder=FakeSparseEmbedder(), + vector_db=vector, + sc_repo=providers, + source_registry=registry, + source_lifecycle=MagicMock(), + access_broker=MagicMock(), + ) + executor._discover_federated_sources = AsyncMock(side_effect=AssertionError("No federation")) + service = OwnedSearchService(executor, registry) + ctx = SimpleNamespace(organization=SimpleNamespace(id=fence.organization_id)) + app = FastAPI() + app.include_router(router, prefix="/sync") + + async def session(): + async with database() as db: + yield db + + from airweave.domains.entities.canonical.query import CanonicalQueryService + from airweave.domains.entities.canonical.query_store import CanonicalQueryStore + from airweave.domains.entities.canonical.store import CanonicalRecordStore + + app.dependency_overrides[deps.get_canonical_query_service] = lambda: CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), "test-extraction-key" + ) + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: ctx + app.dependency_overrides[deps.get_owned_search_context] = lambda: ctx + app.dependency_overrides[deps.get_search_session_factory] = lambda: database + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(owned_search=service) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client, vector, ctx, executor, dense + + +async def test_owned_http_deduplicates_and_preserves_partial_engine(database, indexed, http_search): + fence, locator, connection = indexed + client, vector, _, executor, dense = http_search + first = hit(fence, locator.encode()) + second = first.model_copy( + update={"entity_id": "second-chunk", "textual_representation": "Other evidence"} + ) + vector.seed_results( + SearchResults(results=[first, second], engine_partial=True, engine_coverage_percent=75) + ) + transferred_columns = set() + + def columns_received(connection, cursor, statement, parameters, context, executemany): + if cursor.description: + transferred_columns.update(column[0] for column in cursor.description) + + async with database() as db: + engine = db.bind.sync_engine + event.listen(engine, "after_cursor_execute", columns_received) + try: + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + finally: + event.remove(engine, "after_cursor_execute", columns_received) + assert not transferred_columns.intersection({"source_payload", "blob_references"}) + assert response.status_code == 200, response.text + body = response.json() + assert len(body["items"]) == 1 + item = body["items"][0] + assert item["identity"] == {"record_type": "event", "native_id": "one", "container_id": None} + assert item["source_connection_id"] == str(connection.id) + assert item["record_id"] == str(locator.record_id) + assert item["revision"] == locator.revision and item["sync_id"] == str(fence.sync_id) + assert item["provider"] == "gmail" and item["title"] == first.name + assert item["completeness"] == "complete" and item["observed_at"] + assert item["source_created_at"] is None and item["source_updated_at"] is None + assert item["email_thread_id"] is None + assert item["excerpts"] == [first.textual_representation, "Other evidence"] + assert len(item["excerpts"]) == 2 and "payload" not in item + assert body["engine_partial"] and body["retrieval_incomplete"] + assert body["coverage"] == "bounded_candidates" and "next_cursor" not in body + assert body["sources"][0]["pending_records"] == 0 + executor._discover_federated_sources.assert_not_called() + dense.embed_many.assert_not_called() + compiled = vector._calls[0][1] + assert compiled.filter_groups[0].conditions[0].value == [str(fence.sync_id)] + + +async def test_owned_http_rejects_foreign_unavailable_and_unknown_controls( + database, indexed, http_search +): + fence, _, _ = indexed + client, vector, ctx, _, _ = http_search + base = {"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"} + for invalid in ( + {"sort": "date"}, + {"offset": 10}, + {"limit": 201}, + {"filter": []}, + {"cursor": "next"}, + ): + assert (await client.post("/sync/search", json={**base, **invalid})).status_code == 422 + assert ( + await client.post("/sync/search", json={**base, "record_types": ["unknown"]}) + ).status_code == 422 + assert ( + await client.post("/sync/search", json={**base, "sync_ids": [str(uuid4())]}) + ).status_code == 404 + ctx.organization.id = uuid4() + assert (await client.post("/sync/search", json=base)).status_code == 404 + ctx.organization.id = fence.organization_id + async with database() as db: + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + assert (await client.post("/sync/search", json=base)).status_code == 404 + assert vector._calls == [] + + +async def test_owned_http_stale_text_removed_and_lag_explicit( + database, source, indexed, http_search +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + service, _ = source + await capture(database, service, fence, observation(payload={"new": "private"})) + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + body = response.json() + assert response.status_code == 200 and body["items"] == [] + assert body["excluded_candidates"] == 1 and body["sources"][0]["pending_records"] == 1 + assert body["retrieval_incomplete"] + + +async def test_owned_filters_are_explicit_postfilters(indexed, http_search): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + "record_types": ["message"], + }, + ) + assert response.status_code == 200 + assert response.json()["items"] == [] and response.json()["postfilter_excluded"] == 1 + + +async def test_owned_search_rejects_valid_other_account_hit(database, source, indexed, http_search): + from airweave.models import Sync, SyncJob + + fence, _, _ = indexed + client, vector, _, _, _ = http_search + capture_service, _ = source + other_sync, other_job = uuid4(), uuid4() + async with database() as db: + db.add(Sync(id=other_sync, organization_id=fence.organization_id, name="Other")) + await db.flush() + db.add( + SyncJob( + id=other_job, + organization_id=fence.organization_id, + sync_id=other_sync, + status="running", + ) + ) + db.add( + SourceConnection( + name="Other", + short_name="gmail", + organization_id=fence.organization_id, + readable_collection_id="owned-search", + sync_id=other_sync, + is_authenticated=True, + ) + ) + await db.commit() + other_fence = await capture_service.activate_writer( + db, fence.organization_id, other_sync, other_job, attempt_id=uuid4(), attempt_number=1 + ) + await capture(database, capture_service, other_fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, other_sync))[0] + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1) + locator = ProjectionLocator( + record_id=work.record.id, + revision=1, + pipeline_version=1, + generation=generation, + part_index=0, + ) + # Fake engine deliberately violates its compiled sync filter. SQL enrichment + # must still prevent this otherwise-valid same-collection account leak. + vector.seed_results(SearchResults(results=[hit(other_fence, locator.encode())])) + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + assert response.status_code == 200 + assert response.json()["items"] == [] + assert response.json()["excluded_candidates"] == 1 + + +async def test_disconnect_during_retrieval_rejects_whole_response(database, indexed, http_search): + fence, locator, _ = indexed + client, vector, _, executor, _ = http_search + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + execute = executor.execute + + async def disconnected(**kwargs): + result = await execute(**kwargs) + async with database() as db: + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + return result + + executor.execute = disconnected + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + assert response.status_code == 404 + assert "Private original text" not in response.text + + +async def test_final_gate_drops_early_hit_changed_during_later_collection( + database, source, indexed, http_search +): + from airweave.models import Sync, SyncJob + + fence, locator, _ = indexed + client, vector, _, executor, _ = http_search + capture_service, _ = source + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + other_sync, other_job = uuid4(), uuid4() + async with database() as db: + deployment = await db.scalar(select(VectorDbDeploymentMetadata)) + db.add( + Collection( + name="Other", + readable_id="later-search", + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + ) + db.add(Sync(id=other_sync, organization_id=fence.organization_id, name="Other")) + await db.flush() + db.add( + SyncJob( + id=other_job, + organization_id=fence.organization_id, + sync_id=other_sync, + status="running", + ) + ) + db.add( + SourceConnection( + name="Other", + short_name="gmail", + organization_id=fence.organization_id, + readable_collection_id="later-search", + sync_id=other_sync, + is_authenticated=True, + ) + ) + await db.commit() + execute = executor.execute + calls = 0 + prepared_queries = [] + + async def later_edit(**kwargs): + nonlocal calls + calls += 1 + prepared_queries.append(kwargs["prepared_query"]) + if calls == 2: + await capture(database, capture_service, fence, observation(payload={"updated": True})) + return SearchResults(results=[]) + return await execute(**kwargs) + + executor.execute = later_edit + # Pin collection order only for this race proof, independently of SQL planner order. + service = next( + value + for key, value in client._transport.app.dependency_overrides.items() + if key is deps.get_container + )().owned_search + resolve = service._resolve_scopes + + async def ordered(*args): + scopes, groups = await resolve(*args) + return scopes, dict(sorted(groups.items(), key=lambda item: item[0][1] != "owned-search")) + + service._resolve_scopes = ordered + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id), str(other_sync)], + "mode": "keyword", + }, + ) + assert prepared_queries[0] is not None and prepared_queries[0] is prepared_queries[1] + assert calls == 2 and response.status_code == 200 + assert response.json()["items"] == [] and response.json()["excluded_candidates"] == 1 + assert "Private original text" not in response.text + + +@pytest.mark.parametrize( + "provider,payload,expected", + [ + ("gmail", {"threadId": "thread-1"}, "thread-1"), + ("slack", {"threadId": "thread-1"}, None), + ("gmail", {}, None), + ("gmail", {"threadId": None}, None), + ("gmail", {"threadId": 12345}, None), + ("gmail", {"threadId": "bad/id"}, None), + ("gmail", {"threadId": {"bad": "value"}}, None), + ], +) +async def test_email_route_uses_visible_canonical_provider_payload( + database, indexed, http_search, provider, payload, expected +): + from airweave.models.entity import Entity + + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + async with database() as db: + await db.execute(update(SourceConnection).values(short_name=provider)) + await db.execute( + update(Entity).values(entity_definition_short_name="message", source_payload=payload) + ) + await db.commit() + candidate = hit(fence, locator.encode()) + candidate.airweave_system_metadata.source_name = provider + vector.seed_results(SearchResults(results=[candidate])) + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + assert response.status_code == 200, response.text + assert response.json()["items"][0]["email_thread_id"] == expected + + +async def test_filtered_search_requires_explicit_reprojection(database, indexed, http_search): + fence, _, _ = indexed + client, vector, _, _, _ = http_search + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(index_pipeline_version=1) + ) + await db.commit() + result = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "record_types": ["event"]}, + ) + assert result.status_code == 409 + assert result.json()["detail"]["code"] == "reindex_required" + assert vector._calls == [] + + +async def test_dates_and_types_prefilter_before_candidate_limit(indexed, http_search): + fence, _, _ = indexed + client, vector, _, _, _ = http_search + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + "record_types": ["event"], + "created_after": "2026-01-01T00:00:00.000001Z", + "updated_before": "2026-02-01T00:00:00Z", + }, + ) + assert response.status_code == 200, response.text + conditions = vector._calls[0][1].filter_groups[0].conditions + by_field = {condition.field.value: condition.value for condition in conditions} + assert by_field["airweave_system_metadata.canonical_record_type"] == ["event"] + assert by_field["airweave_system_metadata.source_created_known"] == 1 + assert by_field["airweave_system_metadata.source_created_us"] == 1767225600000001 + assert by_field["airweave_system_metadata.source_updated_known"] == 1 + + +async def test_capture_discovery_coverage_is_separate_from_retrieval( + database, source, indexed, http_search +): + from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration + + service, fence = source + async with database() as db: + await service.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=CycleConfiguration( + fingerprint="d" * 64, + parents={"event": (None,)}, + completion_policies={"event": "discovery_only"}, + ), + ), + ) + client, vector, _, _, _ = http_search + vector.seed_results(SearchResults(results=[])) + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + }, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["sources"][0]["capture"] == { + "phase": "active", + "mode": "full", + "scope_summary": None, + "last_full_capture": None, + "provider_checkpoint_promoted_at": None, + "policies": {"event": "discovery_only"}, + "discovery": "incomplete", + } + assert body["sources"][0]["extraction_unknown_records"] == 1 + assert body["retrieval_incomplete"] + + +async def test_partial_extraction_http_hit_original_and_nonindexed_part_gate( + database, indexed, http_search +): + from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, + ) + from airweave.models.projection_generation import ProjectionGeneration + + fence, locator, _ = indexed + coverage = ExtractionCoverage( + parts=( + ExtractionOutcome(part_index=0, key="/body", kind="body", outcome="indexed"), + ExtractionOutcome( + part_index=1, + key="/payload/parts/1", + kind="file", + media_type="video/mp4", + extension=".mp4", + outcome="unsupported", + reason="unsupported_format", + ), + ) + ) + async with database() as db: + await db.execute( + update(ProjectionGeneration) + .where(ProjectionGeneration.id == locator.generation) + .values(extraction_coverage=coverage.persisted()) + ) + await db.commit() + client, vector, _, _, _ = http_search + valid = hit(fence, locator.encode()) + invalid = hit(fence, locator.model_copy(update={"part_index": 1}).encode()) + invalid.textual_representation = "unsupported attachment must never escape" + vector.seed_results(SearchResults(results=[valid, invalid])) + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert len(payload["items"]) == 1 and payload["retrieval_incomplete"] + assert payload["items"][0]["extraction"] == coverage.model_dump(mode="json") + assert "unsupported attachment must never escape" not in str(payload["items"]) + assert payload["sources"][0]["partially_indexed_records"] == 1 + response = await client.get(f"/sync/{fence.sync_id}/records/{locator.record_id}") + assert response.status_code == 200, response.text + assert response.json()["completeness"] == "complete" + assert response.json()["extraction"] == coverage.model_dump(mode="json") + async with database() as db: + await db.execute( + update(Entity).where(Entity.id == locator.record_id).values(record_revision=2) + ) + await db.commit() + response = await client.get(f"/sync/{fence.sync_id}/records/{locator.record_id}") + assert response.json()["extraction"] is None + + +async def test_visibility_batches_exact_parts_generations_and_duplicate_chunks(database, indexed): + from airweave.domains.search.canonical_visibility import visible_results + from airweave.models.projection_generation import ProjectionGeneration + + fence, locator, connection = indexed + registry = SimpleNamespace( + get=lambda _: SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("event",)) + ) + ) + async with database() as db: + await db.execute( + update(ProjectionGeneration) + .where(ProjectionGeneration.id == locator.generation) + .values( + extraction_coverage={ + "parts": [ + {"part_index": 0, "key": "body", "kind": "body", "outcome": "indexed"}, + { + "part_index": 1, + "key": "file", + "kind": "file", + "outcome": "unsupported", + "reason": "unsupported_format", + }, + ], + } + ) + ) + await db.commit() + valid = hit(fence, locator.encode()) + inputs = [ + valid, + hit(fence, locator.model_copy(update={"part_index": 1}).encode()), + hit(fence, locator.model_copy(update={"generation": uuid4()}).encode()), + hit(fence, locator.model_copy(update={"pipeline_version": 1}).encode()), + valid, + valid.model_copy( + update={ + "airweave_system_metadata": valid.airweave_system_metadata.model_copy( + update={"sync_id": str(uuid4())} + ) + } + ), + ] + assert await visible_results( + db, fence.organization_id, connection.readable_collection_id, inputs, registry + ) == [valid, valid] + + +@pytest.mark.parametrize( + "mode,snippet,expected_fragment", + [ + ( + "keyword", + "नमस्ते 原文 ", + "… नमस्ते 原文 …", + ), + ("hybrid", "… नमस्ते 原文", "… नमस्ते 原文"), + ("semantic", "… नमस्ते 原文", None), + ("hybrid", "An unmatched lead without term markers", None), + ("keyword", None, None), + ], +) +async def test_owned_lexical_fragment_keeps_original_chunk( + database, indexed, http_search, mode, snippet, expected_fragment +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + http_search[3]._dense_embedder = FakeDenseEmbedder(dimensions=3) + original = "Leading context. " * 200 + "नमस्ते 原文" + candidate = hit(fence, locator.encode()).model_copy( + update={ + "textual_representation": original, + "query_snippet": snippet, + } + ) + vector.seed_results(SearchResults(results=[candidate])) + response = await client.post( + "/sync/search", + json={ + "query": "नमस्ते", + "sync_ids": [str(fence.sync_id)], + "mode": mode, + }, + ) + assert response.status_code == 200, response.text + expected = expected_fragment if expected_fragment is not None else original[:2000] + assert response.json()["items"][0]["excerpts"] == [expected] + assert response.json()["items"][0]["record_id"] == str(locator.record_id) + assert candidate.textual_representation == original diff --git a/backend/airweave/domains/entities/canonical/tests/test_page_child_receipts.py b/backend/airweave/domains/entities/canonical/tests/test_page_child_receipts.py new file mode 100644 index 0000000000..bc779b28f8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_page_child_receipts.py @@ -0,0 +1,320 @@ +"""Native page observations atomically attest existing child scans without another queue.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from airweave.domains.entities.canonical.cycle_models import BeginCycle +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import CompletedScope, parent_container_key +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + ChildScopeObservation, + CommitScanPage, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.store import StaleWriter +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.entities.canonical.tests.test_cycles import finish, page, scan +from airweave.domains.entities.canonical.tests.test_exact_parent_validation import CONFIG +from airweave.domains.entities.canonical.tests.test_forest_scans import collect, item +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity + + +async def history(database, source, config=CONFIG): + service, fence = source + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + channel = item("channel", "C") + await collect(database, service, fence, cycle, CompletedScope(record_type="channel"), channel) + messages = tuple(item("message", name, channel.identity) for name in ("one", "two")) + state = await scan( + database, + service, + fence, + cycle, + CompletedScope( + record_type="message", + container_id=messages[0].identity.container_id, + parent=channel.identity, + ), + ) + return cycle, channel, messages, state + + +def declaration(message): + return ChildScopeObservation( + scope=CompletedScope( + record_type="file", + container_id=parent_container_key(message.identity), + parent=message.identity, + ) + ) + + +def request(fence, state, messages, declarations=None): + return CommitScanPage( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + records=messages, + continuation=ScanContinuation(value={"next": str(state.version.revision)}), + child_scope_observations=( + tuple(declaration(m) for m in messages) if declarations is None else declarations + ), + ) + + +async def commit(database, service, request): + async with database() as db: + return await service.commit_scan_page(db, request) + + +@pytest.mark.parametrize("completed", [False, True]) +async def test_page_receipts_include_unchanged_owners_and_preserve_child_progress( + database, source, completed +): + service, fence = source + _, _, messages, state = await history(database, source) + # Complete child inventory does not imply complete unrelated body coverage. + messages = (messages[0].model_copy(update={"completeness": "partial"}), messages[1]) + async with database() as db: + before_cycle = await service.read_cycle(db, fence) + result = await commit(database, service, request(fence, state, messages)) + async with database() as db: + cycle = await service.read_cycle(db, fence) + children = [await service.read_scan(db, fence, declaration(m).scope) for m in messages] + assert cycle.version.revision == before_cycle.version.revision + len(messages) + assert all(c.parent_verified_attempt_id == fence.attempt_id for c in children) + child = await page( + database, + service, + fence, + children[0], + item("file", "F", messages[0].identity), + final=completed, + ) + if completed: + child = await finish(database, service, fence, child) + repeated = await commit(database, service, request(fence, result.state, messages)) + assert repeated.capture.unchanged == 2 and repeated.capture.changes == () + async with database() as db: + current = await service.read_scan(db, fence, child.scope) + owner = await db.scalar(select(Entity).where(Entity.native_id == "one")) + assert current.version.sweep_id == child.version.sweep_id + assert current.continuation == child.continuation and current.phase == child.phase + assert current.parent_verified_revision == owner.record_revision == 1 + assert current.parent_visibility_epoch == owner.visibility_epoch + + +async def test_changed_inventory_restarts_child_and_hides_removed_attachment(database, source): + service, fence = source + _, _, messages, state = await history(database, source) + message = messages[0].model_copy( + update={"payload": {"files": ["F"]}, "descendant_visibility_fields": ("files",)} + ) + result = await commit(database, service, request(fence, state, (message,))) + async with database() as db: + child = await service.read_scan(db, fence, declaration(message).scope) + child = await page( + database, service, fence, child, item("file", "F", message.identity), final=True + ) + child = await finish(database, service, fence, child) + changed = message.model_copy(update={"payload": {"files": []}}) + await commit(database, service, request(fence, result.state, (changed,))) + async with database() as db: + current = await service.read_scan(db, fence, child.scope) + file = await db.scalar(select(Entity).where(Entity.native_id == "F")) + retained = await service.store.read(db, fence.organization_id, fence.sync_id, file.id) + assert retained.content_access == "unavailable" + assert current.phase == "collecting" and current.continuation == ScanContinuation() + assert current.version.sweep_id != child.version.sweep_id + assert current.parent_verified_revision == 2 + empty = await page(database, service, fence, current, final=True) + await finish(database, service, fence, empty) + async with database() as db: + assert (await db.get(Entity, file.id)).deleted_at is not None + + +@pytest.mark.parametrize( + "invalid", + [ + "outside_page", + "duplicate", + "duplicate_namespace", + "delete", + "reparent", + "relationship", + "not_opted_in", + ], +) +async def test_page_rejects_unqualified_child_receipts_without_advancing(database, source, invalid): + service, fence = source + config = ( + CONFIG.model_copy(update={"exact_parent_validation": ()}) + if invalid == "not_opted_in" + else CONFIG + ) + _, _, messages, state = await history(database, source, config) + message = messages[0] + declared = declaration(message) + if invalid == "outside_page": + await capture(database, service, fence, messages[1]) + declared = declaration(messages[1]) + elif invalid == "delete": + message = message.model_copy( + update={"kind": "delete", "removal_reason": "provider_deleted"} + ) + elif invalid == "reparent": + message = message.model_copy(update={"allow_reparent": True}) + elif invalid == "relationship": + declared = declared.model_copy( + update={"scope": declared.scope.model_copy(update={"record_type": "channel"})} + ) + declarations = (declared, declared) if invalid == "duplicate" else (declared,) + if invalid == "duplicate_namespace": + declarations = ( + declared, + declared.model_copy( + update={"scope": declared.scope.model_copy(update={"container_id": "other"})} + ), + ) + with pytest.raises(ScanConflict): + await commit(database, service, request(fence, state, (message,), declarations)) + async with database() as db: + assert (await service.read_scan(db, fence, state.scope)).version == state.version + assert await db.scalar( + select(func.count()) + .select_from(Entity) + .where(Entity.entity_definition_short_name == "message") + ) == (1 if invalid == "outside_page" else 0) + assert ( + await db.scalar( + select(func.count()) + .select_from(CaptureScan) + .where(CaptureScan.record_type == "file") + ) + == 0 + ) + + +async def test_receipt_failure_rolls_back_originals_scans_and_page_cursor( + database, source, monkeypatch +): + service, fence = source + _, _, messages, state = await history(database, source) + original = service.scans._begin_verified + calls = 0 + + async def fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + value = await original(*args, **kwargs) + if calls == 2: + raise RuntimeError("synthetic crash after second receipt") + return value + + monkeypatch.setattr(service.scans, "_begin_verified", fail_second) + with pytest.raises(RuntimeError, match="synthetic crash"): + await commit(database, service, request(fence, state, messages)) + async with database() as db: + assert (await service.read_scan(db, fence, state.scope)).version == state.version + assert ( + await db.scalar( + select(func.count()) + .select_from(Entity) + .where(Entity.entity_definition_short_name == "message") + ) + == 0 + ) + assert ( + await db.scalar( + select(func.count()) + .select_from(CaptureScan) + .where(CaptureScan.record_type == "file") + ) + == 0 + ) + + +async def test_new_writer_needs_new_observation_and_rejects_old_writer(database, source): + service, fence = source + cycle, channel, messages, state = await history(database, source) + result = await commit(database, service, request(fence, state, messages)) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + root = await service.read_scan(db, newer, CompletedScope(record_type="channel")) + with pytest.raises(StaleWriter): + await commit(database, service, request(fence, result.state, messages)) + root = await scan( + database, service, newer, cycle, root.scope, expected=root.version, restart=True + ) + root = await page(database, service, newer, root, channel, final=True) + await finish(database, service, newer, root) + async with database() as db: + old_child = await service.read_scan(db, newer, declaration(messages[1]).scope) + old_admission = BeginScan( + fence=newer, + scope=old_child.scope, + cycle_id=cycle.version.cycle_id, + fingerprint=CONFIG.fingerprint, + expected=old_child.version, + expected_parent_epoch=old_child.parent_visibility_epoch, + ) + with pytest.raises(CycleConflict, match="fresh exact"): + async with database() as db: + await service.begin_scan(db, old_admission) + await commit(database, service, request(newer, result.state, (messages[0],))) + async with database() as db: + fresh = await service.read_scan(db, newer, declaration(messages[0]).scope) + old = await service.read_scan(db, newer, old_child.scope) + assert fresh.parent_verified_attempt_id == newer.attempt_id + assert old.parent_verified_attempt_id == fence.attempt_id + + +async def test_withdrawn_ancestor_rejects_page_receipts(database, source): + service, fence = source + _, channel, messages, state = await history(database, source) + await capture( + database, + service, + fence, + channel.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + with pytest.raises(CycleConflict, match="visible parent"): + await commit(database, service, request(fence, state, messages)) + async with database() as db: + assert ( + await db.scalar( + select(func.count()) + .select_from(CaptureScan) + .where(CaptureScan.record_type == "file") + ) + == 0 + ) + + +async def test_established_child_namespace_change_rolls_back_page(database, source): + service, fence = source + _, _, messages, state = await history(database, source) + result = await commit(database, service, request(fence, state, messages)) + declared = declaration(messages[0]) + wrong = declared.model_copy( + update={"scope": declared.scope.model_copy(update={"container_id": "other"})} + ) + with pytest.raises(CycleConflict, match="identity mapping changed"): + await commit(database, service, request(fence, result.state, (messages[0],), (wrong,))) + async with database() as db: + assert (await service.read_scan(db, fence, state.scope)).version == result.state.version + assert (await service.read_scan(db, fence, declared.scope)).scope == declared.scope diff --git a/backend/airweave/domains/entities/canonical/tests/test_parent_visibility.py b/backend/airweave/domains/entities/canonical/tests/test_parent_visibility.py new file mode 100644 index 0000000000..c8de7ee6de --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_parent_visibility.py @@ -0,0 +1,84 @@ +"""Real database proof of immediate visibility and retryable descendant removals.""" + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.query_models import RecordFilters +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.models import Entity, EntityChange + +pytestmark = pytest.mark.integration + + +async def test_parent_deletion_hides_content_before_sweep_and_replay_recovers(database, source): + service, fence = source + parent_id = RecordIdentity(record_type="calendar", native_id="cal") + parent = observation(identity=parent_id) + child = observation( + "event", + "cal", + parent=parent_id, + blobs=(BlobReference(key="immutable", sha256="a" * 64, size_bytes=7),), + ) + initial = await capture(database, service, fence, parent, child) + child_id = initial.changes[1].record.id + tombstone = parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}) + await capture(database, service, fence, tombstone) + + # Simulate a crash after the parent transaction, before any child cleanup. + async with database() as db: + persisted = await db.get(Entity, child_id) + assert persisted.deleted_at is None + exact = await service.store.read(db, fence.organization_id, fence.sync_id, child_id) + assert exact.content_access == "unavailable" + assert exact.payload == {} and exact.blobs == () + visible = await CanonicalQueryStore().list_records( + db, fence.organization_id, fence.sync_id, RecordFilters(), after_id=None, limit=100 + ) + assert not visible + history = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert all(change.record.content_access == "unavailable" for change in history.changes) + assert all(change.record.payload == {} for change in history.changes) + snapshot = await db.scalar(select(EntityChange.snapshot).where(EntityChange.sequence == 2)) + assert snapshot["payload"]["summary"] == "Synthetic event" + assert snapshot["blobs"][0]["key"] == "immutable" + + replay = await capture(database, service, fence, tombstone) + assert replay.unchanged == 1 + async with database() as db: + swept = await service.reconcile_parents(db, fence) + assert len(swept.capture.changes) == 1 + assert swept.capture.changes[0].record.id == child_id + assert swept.capture.changes[0].record.removal_reason == "access_revoked" + async with database() as db: + repeated = await service.reconcile_parents(db, fence) + assert not repeated.capture.changes + async with database() as db: + history = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert [item.sequence for item in history.changes] == [1, 2, 3, 4] + assert history.changes[-1].kind == "delete" + assert history.changes[-1].record.content_access == "unavailable" + + +@pytest.mark.parametrize("reason", ["access_revoked", "scope_removed"]) +async def test_revoked_root_tombstones_redact_retained_payload(database, source, reason): + service, fence = source + record = observation(kind="delete", removal_reason=reason) + result = await capture(database, service, fence, record) + async with database() as db: + exact = await service.store.read( + db, fence.organization_id, fence.sync_id, result.changes[0].record.id + ) + assert exact.content_access == "unavailable" and exact.payload == {} + listed = await CanonicalQueryStore().list_records( + db, + fence.organization_id, + fence.sync_id, + RecordFilters(state="deleted"), + after_id=None, + limit=100, + ) + assert len(listed) == 1 + assert listed[0].content_access == "unavailable" and listed[0].payload == {} diff --git a/backend/airweave/domains/entities/canonical/tests/test_postgres.py b/backend/airweave/domains/entities/canonical/tests/test_postgres.py new file mode 100644 index 0000000000..72eb3062cc --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_postgres.py @@ -0,0 +1,344 @@ +"""Real PostgreSQL proofs; never connect without an explicit synthetic-test URL.""" + +import asyncio +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, func, select, text, update + +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CompletedScope, + ReconcileScope, +) +from airweave.domains.entities.canonical.store import ( + SourceNotFound, + StaleWriter, + WriterBusy, +) +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.models import Entity, EntityChange, Sync, SyncCursor, SyncJob + +pytestmark = pytest.mark.integration + + +async def test_identity_replay_metadata_tombstone_and_historical_snapshots(database, source): + service, fence = source + first = observation(container_id="cal-a") + second = observation(container_id="cal-b") + result = await capture(database, service, fence, first, second) + assert [c.sequence for c in result.changes] == [1, 2] + assert result.changes[0].record.id != result.changes[1].record.id + replay = await capture( + database, + service, + fence, + first.model_copy(update={"observed_at": first.observed_at + timedelta(hours=1)}), + ) + assert replay.unchanged == 1 and replay.sequence == 2 and not replay.changes + changed = first.model_copy(update={"payload": {"id": "one", "labels": ["IMPORTANT"]}}) + changed_result = await capture(database, service, fence, changed) + assert changed_result.changes[0].record.revision == 2 + assert changed_result.changes[0].record.content_hash == "same-body" + cancelled = observation( + "never-seen", + "cal-a", + payload={ + "id": "never-seen", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-09-30"}, + }, + kind="delete", + removal_reason="provider_deleted", + ) + deleted = await capture(database, service, fence, cancelled) + assert deleted.changes[0].record.payload["recurringEventId"] == "series" + async with database() as db: + page = await service.store.changes(db, fence.organization_id, fence.sync_id, limit=2) + assert page.has_more and page.next_sequence == 2 + assert page.changes[0].record.payload["summary"] == "Synthetic event" + tail = await service.store.changes( + db, + fence.organization_id, + fence.sync_id, + after=page.next_sequence, + high_watermark=page.high_watermark, + ) + assert [c.sequence for c in tail.changes] == [3, 4] + assert not tail.has_more + assert ( + await service.store.read(db, uuid4(), fence.sync_id, result.changes[0].record.id) + is None + ) + with pytest.raises(SourceNotFound): + await service.store.changes(db, uuid4(), fence.sync_id) + pending = await db.scalar( + select(func.count()) + .select_from(Entity) + .where(Entity.indexed_revision.is_distinct_from(Entity.record_revision)) + ) + assert pending == 3 + + +async def test_rollback_has_no_record_journal_or_sequence_hole(database, source): + service, fence = source + async with database() as db: + await service.store.capture(db, CaptureBatch(fence=fence, records=(observation(),))) + await db.rollback() + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 0 + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 0 + assert await db.scalar(select(Sync.observed_change_sequence)) == 0 + committed = await capture(database, service, fence, observation()) + assert committed.sequence == 1 + + +async def test_parallel_commits_cannot_skip_a_change_watermark(database, source): + service, fence = source + first_ready, release_first, second_started = asyncio.Event(), asyncio.Event(), asyncio.Event() + + async def first_writer(): + async with database() as db: + result = await service.store.capture( + db, CaptureBatch(fence=fence, records=(observation("first"),)) + ) + first_ready.set() + await release_first.wait() + await db.commit() + return result + + async def second_writer(): + await first_ready.wait() + second_started.set() + return await capture(database, service, fence, observation("second")) + + first_task = asyncio.create_task(first_writer()) + second_task = asyncio.create_task(second_writer()) + await asyncio.wait_for(second_started.wait(), 5) + async with database() as db: + page = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert page.high_watermark == 0 and not page.changes + assert not second_task.done() + release_first.set() + a, b = await asyncio.wait_for(asyncio.gather(first_task, second_task), 5) + assert (a.sequence, b.sequence) == (1, 2) + async with database() as db: + page = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert [c.sequence for c in page.changes] == [1, 2] + + +async def test_writer_fences_checkpoint_and_job_deletion_preserves_records(database, source): + service, fence = source + captured = await capture(database, service, fence, observation()) + next_job = uuid4() + async with database() as db: + db.add( + SyncJob( + id=next_job, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + status="running", + ) + ) + await db.commit() + async with database() as db: + with pytest.raises(WriterBusy): + await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + next_job, + attempt_id=uuid4(), + attempt_number=1, + ) + async with database() as db: + await db.execute( + update(SyncJob).where(SyncJob.id == fence.job_id).values(status="completed") + ) + await db.commit() + async with database() as db: + next_fence = await service.activate_writer( + db, fence.organization_id, fence.sync_id, next_job, attempt_id=uuid4(), attempt_number=1 + ) + assert next_fence.epoch == fence.epoch + 1 + async with database() as db: + with pytest.raises(StaleWriter): + await service.save_checkpoint(db, fence, {"history_id": "old"}) + async with database() as db: + await service.save_checkpoint(db, next_fence, {"history_id": "new"}) + async with database() as db: + await db.execute(delete(SyncJob).where(SyncJob.id == fence.job_id)) + await db.commit() + entity = await db.get(Entity, captured.changes[0].record.id) + assert entity is not None and entity.sync_job_id is None + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 1 + assert (await db.scalar(select(SyncCursor.cursor_data)))["history_id"] == "new" + + +async def test_reconcile_only_complete_exact_container_and_keep_marks_seen(database, source): + service, fence = source + keep, removed, outside = ( + observation("keep", "a"), + observation("gone", "a"), + observation("other", "b"), + ) + await capture(database, service, fence, keep, removed, outside) + async with database() as db: + await db.execute( + update(SyncJob).where(SyncJob.id == fence.job_id).values(status="completed") + ) + new_job = SyncJob( + organization_id=fence.organization_id, sync_id=fence.sync_id, status="running" + ) + db.add(new_job) + await db.commit() + new_fence = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + new_job.id, + attempt_id=uuid4(), + attempt_number=1, + ) + kept = await capture(database, service, new_fence, keep) + assert kept.unchanged == 1 + async with database() as db: + result = await service.reconcile_scope( + db, + ReconcileScope( + fence=new_fence, + scope=CompletedScope(record_type="event", container_id="a"), + observed_at=datetime.now(timezone.utc), + ), + ) + assert [c.record.identity.native_id for c in result.capture.changes] == ["gone"] + assert result.capture.changes[0].record.removal_reason == "absent" + assert not result.has_more + assert ( + await db.scalar( + select(func.count()).select_from(Entity).where(Entity.deleted_at.is_(None)) + ) + == 2 + ) + + +async def test_retry_attempt_fences_prior_activity_and_seen_scope(database, source): + service, old = source + await capture(database, service, old, observation("from-first-attempt", "a")) + attempt_id = uuid4() + async with database() as db: + newer = await service.activate_writer( + db, + old.organization_id, + old.sync_id, + old.job_id, + attempt_id=attempt_id, + attempt_number=2, + ) + repeated = await service.activate_writer( + db, + old.organization_id, + old.sync_id, + old.job_id, + attempt_id=attempt_id, + attempt_number=2, + ) + assert repeated == newer and newer.epoch > old.epoch + with pytest.raises(StaleWriter): + await capture(database, service, old, observation("late-old")) + async with database() as db: + with pytest.raises(StaleWriter): + await service.activate_writer( + db, + old.organization_id, + old.sync_id, + old.job_id, + attempt_id=old.attempt_id, + attempt_number=1, + ) + # Seen in an interrupted attempt is not seen in the retry's complete scan. + async with database() as db: + result = await service.reconcile_scope( + db, + ReconcileScope( + fence=newer, + scope=CompletedScope(record_type="event", container_id="a"), + observed_at=datetime.now(timezone.utc), + ), + ) + assert len(result.capture.changes) == 1 + assert result.capture.changes[0].record.deleted_at is not None + + +async def test_cancellation_and_capture_have_a_database_barrier(database, source): + service, fence = source + async with database() as writer: + await service.store.capture(writer, CaptureBatch(fence=fence, records=(observation(),))) + cancellation_started = asyncio.Event() + + async def cancel(): + async with database() as db: + cancellation_started.set() + await db.execute( + update(SyncJob).where(SyncJob.id == fence.job_id).values(status="cancelled") + ) + await db.commit() + + task = asyncio.create_task(cancel()) + await cancellation_started.wait() + assert not task.done() + await writer.commit() + await asyncio.wait_for(task, 5) + # Cancellation has committed; old capture and checkpoint now both fail. + with pytest.raises(StaleWriter): + await capture(database, service, fence, observation("after-cancel")) + async with database() as db: + with pytest.raises(StaleWriter): + await service.save_checkpoint(db, fence, {"history_id": "late"}) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncCursor)) == 0 + + +async def test_service_rolls_back_partial_batch_on_real_database_failure(database, source): + service, fence = source + async with database() as db: + await db.execute( + text(""" + CREATE FUNCTION reject_second_change() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.sequence = 2 THEN RAISE EXCEPTION 'synthetic journal failure'; END IF; + RETURN NEW; + END $$ + """) + ) + await db.execute( + text(""" + CREATE TRIGGER reject_second_change BEFORE INSERT ON entity_change + FOR EACH ROW EXECUTE FUNCTION reject_second_change() + """) + ) + await db.commit() + from sqlalchemy.exc import DBAPIError + + with pytest.raises(DBAPIError, match="synthetic journal failure"): + await capture(database, service, fence, observation("first"), observation("second")) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 0 + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 0 + assert await db.scalar(select(Sync.observed_change_sequence)) == 0 + + +@pytest.mark.parametrize("database", ["legacy"], indirect=True) +async def test_upgrade_preserves_existing_metadata_without_claiming_capture(database): + async with database() as db: + entity = (await db.scalars(select(Entity))).one() + assert entity.entity_id == "legacy-native-key" and entity.hash == "old-hash" + assert entity.record_revision == 0 and entity.source_payload is None + assert entity.native_id is None and entity.indexed_revision is None + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 0 + await db.execute(delete(SyncJob).where(SyncJob.id == entity.sync_job_id)) + await db.commit() + await db.refresh(entity) + assert entity.sync_job_id is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection.py b/backend/airweave/domains/entities/canonical/tests/test_projection.py new file mode 100644 index 0000000000..8f54f6faeb --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection.py @@ -0,0 +1,193 @@ +"""Real PostgreSQL publication races: late feeds never regain search eligibility.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + publication_matches, +) +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + observation, + publish_prepared, +) +from airweave.models import Entity, Sync + +pytestmark = pytest.mark.integration + + +async def pending(database, fence): + async with database() as db: + return await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id) + + +async def test_publication_compare_and_swap_revision_generation_and_pipeline(database, source): + service, fence = source + await bind_projection(database, fence, "slack") + await capture(database, service, fence, observation()) + work = (await pending(database, fence))[0] + store = CanonicalProjectionStore() + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 3) + async with database() as db: + assert not await publish_prepared(store, db, work, uuid4(), 3) # timed-out prior feed + assert not await pending(database, fence) + locator = ProjectionLocator( + record_id=work.record.id, + revision=1, + pipeline_version=1, + generation=generation, + part_index=0, + ) + assert ProjectionLocator.parse(locator.encode()) == locator + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + == work.record.id + ) + await capture(database, service, fence, observation(payload={"summary": "Changed"})) + async with database() as db: + assert ( + await db.scalar(select(Entity.id).join(Sync).where(publication_matches(locator))) + is None + ) + assert not await publish_prepared(store, db, work, uuid4(), 3) + changed = (await pending(database, fence))[0] + async with database() as db: + await db.execute(update(Sync).values(index_pipeline_version=2)) + await db.commit() + async with database() as db: + assert not await publish_prepared(store, db, changed, uuid4(), 3) + assert (await pending(database, fence))[0].pipeline_version == 2 + + +async def test_parent_access_loss_fences_publication_and_tombstone_is_empty(database, source): + service, fence = source + await bind_projection(database, fence, "slack") + parent_id = RecordIdentity(record_type="calendar", native_id="cal") + parent = observation(identity=parent_id) + child = observation("event", "cal", parent=parent_id) + await capture(database, service, fence, parent, child) + rows = await pending(database, fence) + child_work = next(row for row in rows if row.record.identity.record_type == "event") + await capture( + database, + service, + fence, + parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + store = CanonicalProjectionStore() + async with database() as db: + assert not await publish_prepared(store, db, child_work, uuid4(), 2) + rows = await pending(database, fence) + assert len(rows) == 1 and rows[0].record.deleted_at is not None + async with database() as db: + assert await publish_prepared(store, db, rows[0], uuid4(), 0) + assert not await pending(database, fence) + + +async def test_failed_work_retries_and_stale_failure_preserves_publication(database, source): + service, fence = source + await bind_projection(database, fence, "slack") + await capture(database, service, fence, observation()) + work = (await pending(database, fence))[0] + store = CanonicalProjectionStore() + async with database() as db: + await store.fail(db, work, "conversion failed") + assert len(await pending(database, fence)) == 1 + async with database() as db: + assert await publish_prepared(store, db, work, uuid4(), 1) + async with database() as db: + await store.fail(db, work, "late failure") + async with database() as db: + assert (await db.get(Entity, work.record.id)).projection_error is None + + +def test_locator_rejects_malformed_canonical_values(): + assert ProjectionLocator.parse("legacy-native-id") is None + for value in ("canonical:v2:bad", "canonical:v1:bad", "canonical:"): + with pytest.raises(ValueError): + ProjectionLocator.parse(value) + + +async def test_owned_payload_mapper_to_publication_pipeline(database, source): + from unittest.mock import AsyncMock, MagicMock + + from airweave.domains.entities.canonical.projector import CanonicalProjector + from airweave.platform.destinations.vespa.transformer import EntityTransformer + + service, fence = source + binding = await bind_projection(database, fence, "slack") + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type="channel", native_id="C1"), + payload={"id": "C1", "name": "general", "purpose": {"value": "Project work"}}, + ), + ) + work = (await pending(database, fence))[0] + processor = MagicMock() + + async def process(entities, context, runtime, *, strict, expected_ids): + assert strict and context.source_short_name == "slack" + assert entities[0].name == "general" + assert entities[0].airweave_system_metadata.sync_id == fence.sync_id + locator = ProjectionLocator.parse(entities[0].entity_id) + assert locator.record_id == work.record.id + chunk = entities[0].model_copy(deep=True) + chunk.airweave_system_metadata.original_entity_id = chunk.entity_id + chunk.entity_id += "__chunk_0" + return [chunk] + + from airweave.domains.sync_pipeline.pipeline.text_models import BuiltText, BuiltTextBatch + + async def build_text(entities, context, runtime, *, native_bodies=None): + return BuiltTextBatch( + entities=entities, + representations=tuple( + BuiltText(entity_id=e.entity_id, text="Synthetic complete text") for e in entities + ), + ) + + processor.build_text = build_text + processor.process_built_text = process + destination = MagicMock() + destination.collection_id = binding.collection_id + destination.prepare_documents = lambda chunks: { + "base_entity": [ + EntityTransformer(collection_id=destination.collection_id).transform(chunk) + for chunk in chunks + ] + } + + async def feed_prepared(documents): + from airweave.models.projection_generation import ProjectionGeneration + + async with database() as db: + manifest = await db.scalar(select(ProjectionGeneration)) + assert manifest is not None + assert manifest.documents[0]["document_id"] == documents["base_entity"][0].id + assert (await db.get(Entity, work.record.id)).indexed_generation is None + document = documents["base_entity"][0] + assert document.id.startswith(f"{fence.sync_id}_{destination.collection_id}_") + # Direct reads/navigation query entity fields, not the remote feed key. + original = document.fields["airweave_system_metadata_original_entity_id"] + assert ProjectionLocator.parse(original).record_id == work.record.id + assert document.fields["entity_id"] == original + "__chunk_0" + assert document.fields["airweave_system_metadata_sync_id"] == str(fence.sync_id) + + destination.feed_prepared = AsyncMock(side_effect=feed_prepared) + storage = MagicMock(write_file=AsyncMock()) + projector = CanonicalProjector(CanonicalProjectionStore(), database, processor, storage) + assert await projector.project_one(work, "slack", destination, MagicMock()) + destination.feed_prepared.assert_awaited_once() + assert not await pending(database, fence) diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection_admission.py b/backend/airweave/domains/entities/canonical/tests/test_projection_admission.py new file mode 100644 index 0000000000..a93ef8295d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection_admission.py @@ -0,0 +1,176 @@ +"""Authenticated binding and current-input admission before external projection work.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + observation, + publish_prepared, +) +from airweave.models import Entity, Organization, Sync +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection + + +async def test_pending_requires_exact_authenticated_tenant_binding(database, source): + service, fence = source + await capture(database, service, fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + assert not await store.pending(db, fence.organization_id, fence.sync_id) + binding = await bind_projection(database, fence) + async with database() as db: + assert len(await store.pending(db, fence.organization_id, fence.sync_id)) == 1 + assert not await store.pending(db, uuid4(), fence.sync_id) + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + async with database() as db: + assert not await store.pending(db, fence.organization_id, fence.sync_id) + foreign = Organization(name="Other synthetic tenant") + db.add(foreign) + await db.flush() + await db.execute(update(SourceConnection).values(is_authenticated=True)) + await db.execute( + update(Collection) + .where(Collection.id == binding.collection_id) + .values(organization_id=foreign.id) + ) + await db.commit() + async with database() as db: + assert not await store.pending(db, fence.organization_id, fence.sync_id) + + +@pytest.mark.parametrize("change", ["auth", "revision", "pipeline", "source", "collection"]) +async def test_stale_work_is_denied_before_mapper_storage_or_processor( + database, source, monkeypatch, change +): + service, fence = source + binding = await bind_projection(database, fence) + await capture(database, service, fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + if change == "revision": + await capture(database, service, fence, observation(payload={"summary": "new"})) + else: + async with database() as db: + if change == "auth": + await db.execute(update(SourceConnection).values(is_authenticated=False)) + elif change == "pipeline": + await db.execute(update(Sync).values(index_pipeline_version=2)) + elif change == "source": + await db.execute(update(SourceConnection).values(short_name="slack")) + else: + await db.execute(update(SourceConnection).values(readable_collection_id=None)) + await db.commit() + mapper = MagicMock(side_effect=AssertionError("mapper must not read stale input")) + monkeypatch.setattr("airweave.domains.entities.canonical.projection_mappers.map_record", mapper) + processor, storage = MagicMock(), AsyncMock() + destination = MagicMock(collection_id=binding.collection_id, feed_prepared=AsyncMock()) + project = CanonicalProjector(store, database, processor, storage) + assert not await project.project_one(work, "gmail", destination, MagicMock()) + mapper.assert_not_called() + assert not processor.mock_calls and not storage.mock_calls + destination.feed_prepared.assert_not_awaited() + + +async def test_deauth_after_preparation_rejects_publication_and_failure_write(database, source): + service, fence = source + binding = await bind_projection(database, fence) + await capture( + database, service, fence, observation(kind="delete", removal_reason="provider_deleted") + ) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + assert await store.admit(db, work) + generation = uuid4() + async with database() as db: + assert await store.prepare(db, work, generation, binding.collection_id, ()) + async with database() as db: + # Same Sync fence used by owned provisioning pause/withdraw. + await db.scalar(select(Sync).where(Sync.id == fence.sync_id).with_for_update()) + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + async with database() as db: + assert not await store.publish(db, work, generation, 0) + await store.fail(db, work, "late failure") + row = await db.get(Entity, work.record.id) + assert row.indexed_generation is None and row.projection_error is None + + +async def test_wrong_destination_rejected_before_processing_and_manifest(database, source): + service, fence = source + await bind_projection(database, fence) + await capture(database, service, fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + processor = MagicMock() + project = CanonicalProjector(store, database, processor, AsyncMock()) + assert not await project.project_one( + work, "gmail", MagicMock(collection_id=uuid4()), MagicMock() + ) + assert not processor.mock_calls + async with database() as db: + with pytest.raises(ValueError, match="destination"): + await publish_prepared(store, db, work, uuid4(), 1, collection_id=uuid4()) + + +async def test_publication_waits_for_owned_pause_fence_and_observes_revocation(database, source): + import asyncio + + service, fence = source + binding = await bind_projection(database, fence) + await capture( + database, service, fence, observation(kind="delete", removal_reason="provider_deleted") + ) + store = CanonicalProjectionStore() + generation = uuid4() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + assert await store.prepare(db, work, generation, binding.collection_id, ()) + + async def publish(): + async with database() as db: + return await store.publish(db, work, generation, 0) + + async with database() as paused: + await paused.scalar(select(Sync).where(Sync.id == fence.sync_id).with_for_update()) + publishing = asyncio.create_task(publish()) + try: + # Publication cannot pass the same Sync row held by owned pause. + with pytest.raises(TimeoutError): + await asyncio.wait_for(asyncio.shield(publishing), timeout=0.1) + await paused.execute(update(SourceConnection).values(is_authenticated=False)) + await paused.commit() + assert not await asyncio.wait_for(publishing, timeout=5) + finally: + if not publishing.done(): + publishing.cancel() + await asyncio.gather(publishing, return_exceptions=True) + + +async def test_stale_activity_route_does_not_poison_new_binding_work(database, source): + service, fence = source + binding = await bind_projection(database, fence, "slack") + await capture(database, service, fence, observation()) + project = CanonicalProjector(CanonicalProjectionStore(), database, MagicMock(), AsyncMock()) + result = await project.batch( + fence.organization_id, + fence.sync_id, + "gmail", + MagicMock(collection_id=binding.collection_id), + MagicMock(), + ) + assert result.superseded == 1 and result.failed == 0 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.projection_error is None and row.indexed_generation is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection_exclusions.py b/backend/airweave/domains/entities/canonical/tests/test_projection_exclusions.py new file mode 100644 index 0000000000..f7bdc96a3b --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection_exclusions.py @@ -0,0 +1,150 @@ +"""Deliberate search exclusions publish normally without losing retained originals.""" + +from contextlib import asynccontextmanager +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.projection_inputs import ProjectionInputs +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.domains.native_ingestion.tests.test_ingestion import NOW, bind, ingest +from airweave.domains.native_ingestion.tests.test_projection import knowledge, message, session +from airweave.models import Entity + + +@pytest.mark.parametrize( + "kind", + [ + "meeting_listing", + "scratchpad_listing", + "knowledge", + "message", + "event_occurrence", + "event", + ], +) +async def test_excluded_originals_publish_zero_documents(database, source, kind): + service, fence = source + if kind in {"knowledge", "message"}: + provider = "almanac" + await bind(database, fence, dataset="knowledge" if kind == "knowledge" else "sessions") + snapshots = ( + (knowledge(archived_at=NOW.isoformat()),) + if kind == "knowledge" + else ( + session(), + message( + "Original retained", + import_provenance={ + "legacy_row_id": 1, + "active": False, + "compacted": True, + }, + ), + ) + ) + await ingest(database, fence, *snapshots) + else: + provider = "wispr" if kind.endswith("listing") else "google_calendar" + await bind_projection(database, fence, provider) + payload = {"id": "original", "title": "Original retained"} + if kind == "event": + payload.update( + status="cancelled", + recurringEventId="series", + originalStartTime={"date": "2026-10-01"}, + ) + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type=kind, native_id="original"), + payload=payload, + completeness="metadata_only", + ), + ) + store = CanonicalProjectionStore() + async with database() as db: + work = next( + item + for item in await store.pending(db, fence.organization_id, fence.sync_id) + if item.record.identity.record_type == kind + ) + processor, storage = MagicMock(), MagicMock() + destination = MagicMock(collection_id=work.binding.collection_id) + projector = CanonicalProjector(store, database, processor, storage) + assert await projector.project_one(work, provider, destination, MagicMock()) + processor.build_text.assert_not_called() + destination.feed_prepared.assert_not_called() + reader = CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test") + async with database() as db: + retained = await reader.read(db, fence.organization_id, fence.sync_id, work.record.id) + assert retained.payload == work.record.payload + row = await db.scalar(select(Entity).where(Entity.id == work.record.id)) + assert row.indexed_generation is not None and row.indexed_chunk_count == 0 + assert row.projection_error is None + assert work.record.id not in { + item.record.id for item in await store.pending(db, fence.organization_id, fence.sync_id) + } + + +@pytest.mark.parametrize("record_type", ["unknown", "event_occurrence", "event"]) +async def test_unknown_empty_projection_remains_an_error( + database, source, monkeypatch, record_type +): + service, fence = source + binding = await bind_projection(database, fence, "wispr") + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type=record_type, native_id="original"), + payload={ + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-10-01"}, + }, + completeness="metadata_only", + ), + ) + store = CanonicalProjectionStore() + async with database() as db: + (work,) = await store.pending(db, fence.organization_id, fence.sync_id) + + @asynccontextmanager + async def empty_mapper(*args): + yield ProjectionInputs(parts=()) + + monkeypatch.setattr( + "airweave.domains.entities.canonical.projection_mappers.map_record", empty_mapper + ) + projector = CanonicalProjector(store, database, MagicMock(), MagicMock()) + with pytest.raises(ValueError, match="no required content"): + await projector.project_one( + work, "wispr", MagicMock(collection_id=binding.collection_id), MagicMock() + ) + generation = uuid4() + async with database() as db: + assert await store.prepare( + db, + work, + generation, + binding.collection_id, + (), + coverage=ExtractionCoverage(parts=()), + text_representations=(), + ) + async with database() as db: + with pytest.raises(ValueError, match="indexed content or explicit unavailable extraction"): + await store.publish(db, work, generation, 0) diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection_gc.py b/backend/airweave/domains/entities/canonical/tests/test_projection_gc.py new file mode 100644 index 0000000000..b9f0b0343d --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection_gc.py @@ -0,0 +1,285 @@ +"""Real PostgreSQL retirement fences and repeatable cleanup after late remote writes.""" + +from datetime import datetime, timedelta, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import delete, select + +from airweave.domains.entities.canonical.projection_gc import ProjectionGCStore +from airweave.domains.entities.canonical.projection_models import ( + ProjectionDocument, + ProjectionLocator, + scope_projection_document_id, +) +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.models import Entity, Sync +from airweave.models.projection_generation import ProjectionGeneration + + +async def prepare(database, source, count=3): + service, fence = source + binding = await bind_projection(database, fence) + await capture(database, service, fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + generation = uuid4() + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ).encode() + collection_id = binding.collection_id + docs = tuple( + ProjectionDocument( + schema_name="base_entity", + document_id=scope_projection_document_id( + work.record.sync_id, collection_id, f"Entity_{locator}__chunk_{n}" + ), + ) + for n in range(count) + ) + async with database() as db: + assert await store.prepare(db, work, generation, collection_id, docs) + return store, work, generation, docs + + +async def test_retirement_prevents_late_publish_and_repeats_after_late_feed(database, source): + store, work, generation, docs = await prepare(database, source) + gc = ProjectionGCStore() + now = datetime.now(timezone.utc) + timedelta(hours=2) + async with database() as db: + page = await gc.claim(db, generation, now=now, limit=2) + assert page.documents == docs[:2] + async with database() as db: + assert not await store.publish(db, work, generation, 3) + async with database() as db: + await gc.acknowledge(db, page, now=now, error="HTTPError") + async with database() as db: + row = await db.get(ProjectionGeneration, generation) + assert row.delete_cursor == 0 and row.gc_error == "HTTPError" + now += timedelta(minutes=6) + async with database() as db: + retry = await gc.claim(db, generation, now=now, limit=2) + assert retry.documents == page.documents + async with database() as db: + await gc.acknowledge(db, retry, now=now) + async with database() as db: + tail = await gc.claim(db, generation, now=now, limit=2) + assert tail.documents == docs[2:] + async with database() as db: + await gc.acknowledge(db, tail, now=now) + # A timed-out feed can recreate documents after this full delete pass. + # The retained ledger schedules all exact IDs again; no query-index discovery required. + async with database() as db: + repeated = await gc.claim(db, generation, now=now + timedelta(hours=1), limit=100) + assert repeated.documents == docs + async with database() as db: + await gc.acknowledge(db, tail, now=now) # stale callback cannot ack newer attempt + row = await db.get(ProjectionGeneration, generation) + assert row.gc_attempt == repeated.attempt and row.gc_passes == 1 + + +async def test_current_generation_protected_then_atomically_retired_on_replacement( + database, source +): + store, work, generation, docs = await prepare(database, source) + gc = ProjectionGCStore() + async with database() as db: + assert await store.publish(db, work, generation, len(docs)) + async with database() as db: + assert ( + await gc.claim(db, generation, now=datetime.now(timezone.utc) + timedelta(days=2)) + is None + ) + service, fence = source + await capture(database, service, fence, observation(payload={"changed": True})) + async with database() as db: + newer = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + new_generation = uuid4() + new_docs = tuple( + doc.model_copy( + update={ + "document_id": doc.document_id.replace( + str(generation), str(new_generation) + ).replace(":1:1:", ":2:1:") + } + ) + for doc in docs + ) + async with database() as db: + assert await store.prepare( + db, newer, new_generation, UUID(docs[0].document_id.split("_")[1]), new_docs + ) + async with database() as db: + assert await store.publish(db, newer, new_generation, len(docs)) + async with database() as db: + old = await db.get(ProjectionGeneration, generation) + assert old.retired_at is not None + assert (await db.get(Entity, work.record.id)).indexed_generation == new_generation + + +async def test_source_deletion_does_not_erase_cleanup_obligations(database, source): + store, work, generation, docs = await prepare(database, source) + async with database() as db: + await db.execute(delete(Sync).where(Sync.id == work.record.sync_id)) + await db.commit() + gc = ProjectionGCStore() + async with database() as db: + page = await gc.claim(db, generation, now=datetime.now(timezone.utc) + timedelta(hours=2)) + assert page.documents == docs + async with database() as db: + assert not await store.publish(db, work, generation, len(docs)) + + +async def test_gc_waits_on_publication_lock_and_cannot_retire_current(database, source): + import asyncio + + store, work, generation, docs = await prepare(database, source) + gc = ProjectionGCStore() + entered = asyncio.Event() + async with database() as publishing: + # Hold the real shared lock and publish the pointer in the same transaction. + await publishing.scalar( + select(Sync.id).where(Sync.id == work.record.sync_id).with_for_update() + ) + row = await publishing.get(Entity, work.record.id) + row.indexed_generation = generation + await publishing.flush() + + async def collect(): + entered.set() + async with database() as db: + return await gc.claim( + db, generation, now=datetime.now(timezone.utc) + timedelta(hours=2) + ) + + attempt = asyncio.create_task(collect()) + await entered.wait() + await asyncio.sleep(0.05) + assert not attempt.done() + await publishing.commit() + assert await attempt is None + async with database() as db: + assert (await db.get(ProjectionGeneration, generation)).retired_at is None + + +async def test_deleted_current_generation_retired_without_another_projection(database, source): + store, work, generation, docs = await prepare(database, source) + async with database() as db: + assert await store.publish(db, work, generation, len(docs)) + service, fence = source + await capture( + database, + service, + fence, + observation(kind="delete", removal_reason="provider_deleted", payload={"id": "one"}), + ) + async with database() as db: + page = await ProjectionGCStore().claim( + db, generation, now=datetime.now(timezone.utc) + timedelta(days=2) + ) + assert page.documents == docs + record = await db.get(Entity, work.record.id) + assert record.indexed_generation is None and record.indexed_revision is None + assert record.deleted_at is not None + assert (await db.get(ProjectionGeneration, generation)).retired_at is not None + + +async def test_hidden_parent_retirement_restoration_requires_fresh_generation(database, source): + from airweave.domains.entities.canonical.requests import RecordIdentity + + store, work, generation, docs = await prepare(database, source) + async with database() as db: + assert await store.publish(db, work, generation, len(docs)) + service, fence = source + parent = RecordIdentity(record_type="calendar", native_id="calendar-one") + # Add a real captured parent reference, then simulate the crash window between + # parent revocation and bounded child reconciliation: child remains active. + await capture(database, service, fence, observation(identity=parent)) + await capture(database, service, fence, observation(parent=parent, allow_reparent=True)) + await capture( + database, + service, + fence, + observation( + identity=parent, + kind="delete", + removal_reason="access_revoked", + payload={"id": "calendar-one"}, + ), + ) + async with database() as db: + page = await ProjectionGCStore().claim( + db, generation, now=datetime.now(timezone.utc) + timedelta(days=2) + ) + assert page.documents == docs + assert (await db.get(Entity, work.record.id)).deleted_at is None + await capture(database, service, fence, observation(identity=parent)) + async with database() as db: + assert all( + item.record.id != work.record.id + for item in await store.pending(db, fence.organization_id, fence.sync_id) + ) + # Restoration of the parent alone cannot reauthorize retained descendant bytes. + await capture(database, service, fence, observation(parent=parent)) + async with database() as db: + restored = next( + item + for item in await store.pending(db, fence.organization_id, fence.sync_id) + if item.record.id == work.record.id + ) + assert restored.previous_generation is None + assert not await store.publish(db, restored, generation, len(docs)) + + +async def test_manifest_rejects_foreign_scope_and_unanchored_locator(database, source): + import pytest + + store, work, generation, docs = await prepare(database, source) + collection_id = UUID(docs[0].document_id.split("_")[1]) + for document_id in ( + docs[0].document_id.replace(str(work.record.sync_id), str(uuid4())), + docs[0].document_id.replace(str(collection_id), str(uuid4())), + "arbitrary_" + docs[0].document_id, + docs[0].document_id + "_suffix", + ): + async with database() as db: + with pytest.raises(ValueError, match="Projection manifest"): + await store.prepare( + db, + work, + generation, + collection_id, + (docs[0].model_copy(update={"document_id": document_id}),), + ) + + +async def test_empty_tombstone_publication_does_not_become_pending_each_gc_pass(database, source): + service, fence = source + binding = await bind_projection(database, fence) + await capture( + database, + service, + fence, + observation(kind="delete", removal_reason="provider_deleted", payload={"id": "one"}), + ) + store = CanonicalProjectionStore() + generation = uuid4() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + async with database() as db: + assert await store.prepare(db, work, generation, binding.collection_id, ()) + async with database() as db: + assert await store.publish(db, work, generation, 0) + async with database() as db: + assert ( + await ProjectionGCStore().claim( + db, generation, now=datetime.now(timezone.utc) + timedelta(days=2) + ) + is None + ) + assert not await store.pending(db, fence.organization_id, fence.sync_id) diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection_ocr.py b/backend/airweave/domains/entities/canonical/tests/test_projection_ocr.py new file mode 100644 index 0000000000..94b8897837 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection_ocr.py @@ -0,0 +1,77 @@ +"""OCR-dependent bytes remain captured and pending instead of vanishing from search.""" + +import hashlib +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock + +import fitz +from PIL import Image +from sqlalchemy import select + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.core.logging import logger +from airweave.domains.converters.registry import ConverterRegistry +from airweave.domains.embedders.fakes.embedder import FakeDenseEmbedder, FakeSparseEmbedder +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.models import Entity + + +async def test_scanned_pdf_without_ocr_stays_pending_with_owned_bytes(database, source, tmp_path): + """Use real PDF conversion and SQL; no mocked conversion or tracker behavior.""" + image = BytesIO() + Image.new("RGB", (100, 100), color="blue").save(image, format="PNG") + document = fitz.open() + page = document.new_page() + page.insert_image(page.rect, stream=image.getvalue()) + content = document.tobytes() + document.close() + service, fence = source + binding = await bind_projection(database, fence, "google_drive") + digest = hashlib.sha256(content).hexdigest() + blob = BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="application/pdf", + ) + storage = FilesystemBackend(tmp_path) + await storage.write_file(blob.key, content) + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type="file", native_id="scanned"), + payload={"id": "scanned", "name": "scan.pdf", "mimeType": "application/pdf"}, + blobs=(blob,), + ), + ) + destination = MagicMock(collection_id=binding.collection_id, feed_prepared=AsyncMock()) + store = CanonicalProjectionStore() + projector = CanonicalProjector( + store, + database, + ChunkEmbedProcessor( + ConverterRegistry(ocr_provider=None), FakeDenseEmbedder(), FakeSparseEmbedder() + ), + storage, + ) + result = await projector.batch( + fence.organization_id, fence.sync_id, "google_drive", destination, logger + ) + assert result.failed == 1 and result.published == 0 + destination.prepare_documents.assert_not_called() + destination.feed_prepared.assert_not_awaited() + async with database() as db: + pending = await store.pending(db, fence.organization_id, fence.sync_id) + assert len(pending) == 1 + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.projection_error is not None and row.indexed_generation is None + record = await service.store.read(db, fence.organization_id, fence.sync_id, row.id) + assert record.completeness == "complete" and record.content_access == "available" + assert record.blobs == (blob,) and record.payload["id"] == "scanned" + assert await storage.read_file(blob.key, max_bytes=blob.size_bytes) == content diff --git a/backend/airweave/domains/entities/canonical/tests/test_projection_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_projection_recovery.py new file mode 100644 index 0000000000..0302c91b6b --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_projection_recovery.py @@ -0,0 +1,164 @@ +"""Recovery discovers exact pending scope and never retries failed originals implicitly.""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +from sqlalchemy import select + +from airweave.domains.entities.canonical.projection_models import ProjectionBatchResult +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.models import Collection, Entity, Organization, SourceConnection, Sync, SyncJob +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +async def test_source_pages_skip_failed_indexed_unsupported_and_foreign_rows(database, source): + service, seed = source + foreign = uuid4() + async with database() as db: + db.add(Organization(id=foreign, name="Foreign")) + metadata = VectorDbDeploymentMetadata( + dense_embedder="test", sparse_embedder="test", embedding_dimensions=3 + ) + db.add(metadata) + await db.flush() + for owner, readable in ((seed.organization_id, "owned"), (foreign, "foreign")): + db.add( + Collection( + id=uuid4(), + name=readable, + readable_id=readable, + organization_id=owner, + vector_db_deployment_metadata_id=metadata.id, + ) + ) + await db.commit() + expected = set() + for case in ( + "fresh", + "provider", + "unauthenticated", + "missing-collection", + "foreign-collection", + "tombstone", + "failed", + "indexed", + "unsupported", + "foreign-source", + "foreign-record", + "withdrawn", + ): + sync_id, job_id = uuid4(), uuid4() + async with database() as db: + db.add(Sync(id=sync_id, organization_id=seed.organization_id, name=case)) + await db.flush() + db.add( + SyncJob( + id=job_id, + sync_id=sync_id, + organization_id=seed.organization_id, + status="running", + ) + ) + db.add( + SourceConnection( + id=uuid4(), + sync_id=sync_id, + organization_id=foreign if case == "foreign-source" else seed.organization_id, + name=case, + short_name=( + "legacy" + if case == "unsupported" + else "slack" + if case == "provider" + else "almanac" + ), + is_authenticated=case != "unauthenticated", + readable_collection_id=( + None + if case == "missing-collection" + else "foreign" + if case == "foreign-collection" + else "owned" + ), + ) + ) + await db.commit() + async with database() as db: + fence = await service.activate_writer( + db, + seed.organization_id, + sync_id, + job_id, + attempt_id=uuid4(), + attempt_number=1, + ) + await capture(database, service, fence, observation()) + if case == "tombstone": + await capture( + database, + service, + fence, + observation().model_copy(update={"kind": "delete", "removal_reason": "deleted"}), + ) + async with database() as db: + record = await db.scalar(select(Entity).where(Entity.sync_id == sync_id)) + if case == "failed": + record.projection_error = "ConversionFailed" + elif case == "indexed": + record.indexed_revision = record.record_revision + record.indexed_pipeline_version = 1 + record.indexed_generation = uuid4() + elif case == "foreign-record": + record.organization_id = foreign + elif case == "withdrawn": + record.removal_reason = "access_revoked" + await db.commit() + if case in ("fresh", "provider", "tombstone"): + expected.add(sync_id) + store = CanonicalProjectionStore() + async with database() as db: + first = await store.pending_sources(db, ("almanac", "slack"), limit=2) + assert len(first.sources) == 2 + assert first.next_cursor == first.sources[-1].sync_id + second = await store.pending_sources( + db, ("almanac", "slack"), after_id=first.next_cursor, limit=2 + ) + assert len(second.sources) == 1 and second.next_cursor is None + assert {item.sync_id for item in (*first.sources, *second.sources)} == expected + assert all( + item.organization_id == seed.organization_id + for item in (*first.sources, *second.sources) + ) + assert not (await store.pending_sources(db, ())).sources + + +async def test_recovery_execution_skips_failed_until_original_changes(database, source): + service, fence = source + await bind_projection(database, fence, "almanac") + await capture(database, service, fence, observation()) + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + await store.fail(db, work, "ConversionFailed") + async with database() as db: + assert len(await store.pending(db, fence.organization_id, fence.sync_id)) == 1 + assert not await store.pending(db, fence.organization_id, fence.sync_id, skip_failed=True) + # Isolate only the expensive external projection; exercise the actual batch selector. + projector = CanonicalProjector(store, database, None, None) + projector.project_one = AsyncMock(return_value=True) + logger = AsyncMock() + assert ( + await projector.batch( + fence.organization_id, fence.sync_id, "almanac", None, logger, skip_failed=True + ) + == ProjectionBatchResult() + ) + projector.project_one.assert_not_awaited() + await capture(database, service, fence, observation(payload={"summary": "Changed original"})) + result = await projector.batch( + fence.organization_id, fence.sync_id, "almanac", None, logger, skip_failed=True + ) + assert result.published == 1 + projector.project_one.assert_awaited_once() diff --git a/backend/airweave/domains/entities/canonical/tests/test_provisioning_fence.py b/backend/airweave/domains/entities/canonical/tests/test_provisioning_fence.py new file mode 100644 index 0000000000..3d41de139c --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_provisioning_fence.py @@ -0,0 +1,75 @@ +"""Connection changes invalidate capture independently of worker cancellation.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from airweave.domains.entities.canonical.store import StaleWriter +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.models import Sync, SyncJob + +pytestmark = pytest.mark.integration + + +async def test_reconnect_rejects_existing_writer_without_losing_history(database, source): + service, fence = source + original = await capture(database, service, fence, observation()) + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(provisioning_generation=1) + ) + await db.commit() + + with pytest.raises(StaleWriter): + await capture(database, service, fence, observation("stale-credential-result")) + async with database() as db: + with pytest.raises(StaleWriter): + await service.store.admit_job(db, fence.organization_id, fence.sync_id, fence.job_id) + page = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert page.high_watermark == original.sequence + assert [change.record.identity.native_id for change in page.changes] == ["one"] + + +async def test_managed_job_requires_verified_active_generation_even_on_identical_retry( + database, source +): + service, fence = source + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values( + provisioning_generation=1, status="active" + ) + ) + await db.execute( + update(SyncJob).where(SyncJob.id == fence.job_id).values(provisioning_generation=1) + ) + await db.commit() + + async with database() as db: + with pytest.raises(StaleWriter): + await service.store.admit_job(db, fence.organization_id, fence.sync_id, fence.job_id) + with pytest.raises(StaleWriter): + await service.store.activate_writer( + db, fence.organization_id, fence.sync_id, fence.job_id, + attempt_id=fence.attempt_id, attempt_number=fence.attempt_number, + ) + await db.rollback() + await db.execute( + update(Sync).where(Sync.id == fence.sync_id).values(provisioning_ready_generation=1) + ) + await db.commit() + assert await service.store.admit_job( + db, fence.organization_id, fence.sync_id, fence.job_id + ) == 1 + with pytest.raises(StaleWriter): + await service.store.admit_job(db, uuid4(), fence.sync_id, fence.job_id) + + await capture(database, service, fence, observation("verified")) + async with database() as db: + await db.execute(update(Sync).where(Sync.id == fence.sync_id).values(status="paused")) + await db.commit() + with pytest.raises(StaleWriter): + await capture(database, service, fence, observation("after-disconnect")) + async with database() as db: + assert await db.scalar(select(Sync.observed_change_sequence)) == 1 diff --git a/backend/airweave/domains/entities/canonical/tests/test_query.py b/backend/airweave/domains/entities/canonical/tests/test_query.py new file mode 100644 index 0000000000..af7396b574 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_query.py @@ -0,0 +1,225 @@ +"""Exact query behavior against committed records, plus signed cursor isolation.""" + +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.query import CanonicalQueryService, InvalidRecordCursor +from airweave.domains.entities.canonical.query_models import RecordFilters, RecordListQuery +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.store import CanonicalRecordStore, SourceNotFound +from airweave.domains.entities.canonical.tests.helpers import observation + + +def query_service(): + return CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), "synthetic-test-key" + ) + + +async def test_list_filters_and_cursor_scope(database, source): + capture, fence = source + async with database() as db: + await capture.capture( + db, + CaptureBatch( + fence=fence, + records=( + observation("one", "inbox"), + observation("two", "inbox"), + observation("three", "elsewhere"), + ), + ), + ) + service = query_service() + filters = RecordFilters(container_id="inbox") + async with database() as db: + first = await service.list_records( + db, fence.organization_id, fence.sync_id, RecordListQuery(filters=filters, limit=1) + ) + second = await service.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=filters, limit=1, cursor=first.next_cursor), + ) + assert first.has_more and not second.has_more + assert {row.identity.native_id for row in (*first.records, *second.records)} == { + "one", + "two", + } + assert first.records[0].id < second.records[0].id + assert first.consistency == "live" + with pytest.raises(InvalidRecordCursor): + await service.list_records( + db, fence.organization_id, fence.sync_id, RecordListQuery(cursor=first.next_cursor) + ) + with pytest.raises(InvalidRecordCursor): + await service.list_records( + db, + uuid4(), + fence.sync_id, + RecordListQuery(filters=filters, cursor=first.next_cursor), + ) + with pytest.raises(SourceNotFound): + await service.list_records(db, uuid4(), fence.sync_id, RecordListQuery()) + + +async def test_change_window_continues_then_polls_new_commits(database, source): + capture, fence = source + service = query_service() + async with database() as db: + await capture.capture( + db, CaptureBatch(fence=fence, records=(observation("one"), observation("two"))) + ) + async with database() as db: + first = await service.changes(db, fence.organization_id, fence.sync_id, limit=1) + async with database() as db: + await capture.capture(db, CaptureBatch(fence=fence, records=(observation("three"),))) + async with database() as db: + second = await service.changes( + db, fence.organization_id, fence.sync_id, cursor=first.next_cursor, limit=1 + ) + third = await service.changes( + db, fence.organization_id, fence.sync_id, cursor=second.next_cursor, limit=1 + ) + assert first.high_watermark == second.high_watermark == 2 + assert [first.changes[0].sequence, second.changes[0].sequence, third.changes[0].sequence] == [ + 1, + 2, + 3, + ] + assert first.has_more and not second.has_more and not third.has_more + assert third.high_watermark == 3 + + +async def test_deleted_record_is_readable_but_not_in_active_list(database, source): + capture, fence = source + service = query_service() + async with database() as db: + result = await capture.capture( + db, + CaptureBatch( + fence=fence, + records=(observation("deleted", kind="delete", removal_reason="provider_deleted"),), + ), + ) + record_id = result.changes[0].record.id + async with database() as db: + record = await service.read(db, fence.organization_id, fence.sync_id, record_id) + active = await service.list_records( + db, fence.organization_id, fence.sync_id, RecordListQuery() + ) + deleted = await service.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=RecordFilters(state="deleted")), + ) + assert record.deleted_at is not None + assert not active.records + assert [row.id for row in deleted.records] == [record_id] + + +async def test_parent_filter_is_exact_visible_and_bound_to_continuation(database, source): + from airweave.domains.entities.canonical.query import RecordNotFound + from airweave.domains.entities.canonical.requests import RecordIdentity + from airweave.domains.entities.canonical.tests.helpers import capture as store + + capture, fence = source + parent = observation( + identity=RecordIdentity(record_type="message", native_id="parent", container_id="C1") + ).model_copy( + update={"payload": {"files": ["F1", "F2"]}, "descendant_visibility_fields": ("files",)} + ) + other = observation( + identity=RecordIdentity(record_type="message", native_id="parent", container_id="C2") + ) + children = tuple( + observation( + identity=RecordIdentity(record_type="file", native_id=name, container_id="owned"), + parent=parent.identity, + ) + for name in ("F1", "F2") + ) + unrelated = observation( + identity=RecordIdentity(record_type="file", native_id="F3", container_id="other"), + parent=other.identity, + ) + saved = await store(database, capture, fence, parent, other, *children, unrelated) + ids = {change.record.identity: change.record.id for change in saved.changes} + service = query_service() + filters = RecordFilters(parent_record_id=ids[parent.identity]) + # Exercise the actual query-string boundary with explicit fixture authentication. + from types import SimpleNamespace + + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + + from airweave.api import deps + from airweave.api.v1.endpoints.records import router + from airweave.db.session import get_db + + app = FastAPI() + app.include_router(router, prefix="/sync") + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: SimpleNamespace( + organization=SimpleNamespace(id=fence.organization_id) + ) + app.dependency_overrides[deps.get_canonical_query_service] = lambda: service + async with AsyncClient(transport=ASGITransport(app), base_url="http://fixture") as client: + response = await client.get( + f"/sync/{fence.sync_id}/records", params={"parent_record_id": str(ids[parent.identity])} + ) + assert response.status_code == 200 + assert {item["id"] for item in response.json()["records"]} == { + str(ids[child.identity]) for child in children + } + async with database() as db: + first = await service.list_records( + db, fence.organization_id, fence.sync_id, RecordListQuery(filters=filters, limit=1) + ) + assert first.has_more and first.records[0].parent == parent.identity + with pytest.raises(InvalidRecordCursor): + await service.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery( + filters=RecordFilters(parent_record_id=ids[other.identity]), + cursor=first.next_cursor, + ), + ) + for organization, sync in ((uuid4(), fence.sync_id), (fence.organization_id, uuid4())): + with pytest.raises(RecordNotFound): + await service.list_records(db, organization, sync, RecordListQuery(filters=filters)) + # Attachment inventory changes hide previously attested children on the next page. + await store(database, capture, fence, parent.model_copy(update={"payload": {"files": []}})) + async with database() as db: + page = await service.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=filters, cursor=first.next_cursor), + ) + assert not page.records + await store( + database, + capture, + fence, + parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + async with database() as db: + with pytest.raises(RecordNotFound): + await service.list_records( + db, + fence.organization_id, + fence.sync_id, + RecordListQuery(filters=filters, cursor=first.next_cursor), + ) diff --git a/backend/airweave/domains/entities/canonical/tests/test_real_vespa_projection.py b/backend/airweave/domains/entities/canonical/tests/test_real_vespa_projection.py new file mode 100644 index 0000000000..9d17b5b346 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_real_vespa_projection.py @@ -0,0 +1,220 @@ +"""Actual capture/blob/project/feed/publication/retrieval; fixed vectors, not quality.""" + +import hashlib +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import select, update +from vespa.application import Vespa + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.api import deps +from airweave.api.v1.endpoints.records import router +from airweave.core.logging import logger +from airweave.db.session import get_db +from airweave.domains.converters.registry import ConverterRegistry +from airweave.domains.embedders.fakes.embedder import FakeDenseEmbedder, FakeSparseEmbedder +from airweave.domains.embedders.types import DenseEmbedding, EmbeddingPurpose, SparseEmbedding +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.entities.canonical.tests.vespa_helpers import deploy_schema +from airweave.domains.search.adapters.vector_db.filter_translator import FilterTranslator +from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB +from airweave.domains.search.executor import SearchPlanExecutor +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.models import Entity +from airweave.models.collection import Collection +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.source_connection import SourceConnection +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata +from airweave.platform.destinations.vespa.destination import VespaDestination +from airweave.platform.sources.google_drive import GoogleDriveSource + +pytestmark = pytest.mark.skipif( + os.environ.get("OWNED_VESPA_TEST") != "1", reason="requires disposable real Vespa" +) + + +class FixedDense(FakeDenseEmbedder): + """Only model inference is substituted; all processing stays production code.""" + + async def embed_many( + self, texts: list[str], *, purpose: EmbeddingPurpose = "document" + ) -> list[DenseEmbedding]: + return [DenseEmbedding(vector=[1.0] + [0.0] * 383) for _ in texts] + + +class FixedSparse(FakeSparseEmbedder): + async def embed(self, text): + return SparseEmbedding(indices=[1], values=[1.0]) + + async def embed_many(self, texts): + return [await self.embed(text) for text in texts] + + +async def test_captured_blob_to_published_search_and_withdrawal( + database, source, tmp_path, monkeypatch +): + """Search cannot expose another org, stale revision or withdrawn publication.""" + from airweave.core.config import settings + + monkeypatch.setattr(settings, "VESPA_URL", "http://localhost") + monkeypatch.setattr(settings, "VESPA_PORT", 8081) + async with httpx.AsyncClient(timeout=120) as http: + await deploy_schema(http) + service, fence = source + log = logger.with_context(request_id="synthetic-complete-projection") + storage = FilesystemBackend(tmp_path) + content = b"Synthetic fundraising discussion. The team plans a fundraising meeting." + digest = hashlib.sha256(content).hexdigest() + blob = BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="text/plain", + ) + await storage.write_file(blob.key, content) + original = observation( + identity=RecordIdentity(record_type="file", native_id="fixture-file"), + payload={"id": "fixture-file", "name": "Fundraising.txt", "mimeType": "text/plain"}, + blobs=(blob,), + ) + await capture(database, service, fence, original) + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="fixed", embedding_dimensions=384, sparse_embedder="fixed" + ) + db.add(deployment) + await db.flush() + collection = Collection( + name="Synthetic", + readable_id="projection-" + uuid4().hex, + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + db.add(collection) + await db.flush() + connection = SourceConnection( + name="Synthetic", + short_name="google_drive", + organization_id=fence.organization_id, + readable_collection_id=collection.readable_id, + sync_id=fence.sync_id, + is_authenticated=True, + ) + db.add(connection) + await db.commit() + dense, sparse = FixedDense(dimensions=384), FixedSparse() + store = CanonicalProjectionStore() + projector = CanonicalProjector( + store, database, ChunkEmbedProcessor(ConverterRegistry(), dense, sparse), storage + ) + destination = await VespaDestination.create( + collection_id=collection.id, organization_id=fence.organization_id, logger=log + ) + registry = SimpleNamespace(get=lambda _: SimpleNamespace(source_class_ref=GoogleDriveSource)) + blocked = AsyncMock(side_effect=AssertionError("No provider or model-agent call allowed")) + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=log, + filter_translator=FilterTranslator(logger=log), + ) + executor = SearchPlanExecutor(dense, sparse, engine, blocked, registry, blocked, blocked) + owned = OwnedSearchService(executor, registry) + ctx = SimpleNamespace(organization=SimpleNamespace(id=fence.organization_id), logger=log) + app = FastAPI() + app.include_router(router, prefix="/sync") + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: ctx + app.dependency_overrides[deps.get_owned_search_context] = lambda: ctx + app.dependency_overrides[deps.get_search_session_factory] = lambda: database + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(owned_search=owned) + request = {"query": "fundraising", "sync_ids": [str(fence.sync_id)], "mode": "keyword"} + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://test" + ) as api: + before = await api.post("/sync/search", json=request) + assert before.status_code == 200 and before.json()["items"] == [] + assert before.json()["sources"][0]["pending_records"] == 1 + batch = await projector.batch( + fence.organization_id, fence.sync_id, "google_drive", destination, log + ) + assert batch.published == 1 and batch.failed == 0 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + assert row.indexed_generation is not None and row.indexed_chunk_count > 0 + record_id = str(row.id) + manifest = await db.scalar(select(ProjectionGeneration)) + assert manifest.documents + for mode in ("keyword", "semantic", "hybrid"): + result = await api.post("/sync/search", json={**request, "mode": mode}) + assert result.status_code == 200, result.text + assert [hit["record_id"] for hit in result.json()["items"]] == [record_id] + excerpt = " ".join(result.json()["items"][0]["excerpts"]) + # Lexical summaries may select the matching filename rather than + # the body sentence. Semantic mode retains the full chunk fallback. + assert "fundraising" in excerpt.casefold() + assert "" not in excerpt and "" not in excerpt + if mode == "semantic": + assert "fundraising meeting" in excerpt + assert result.json()["sources"][0]["pending_records"] == 0 + changed = original.model_copy( + update={"payload": {**original.payload, "name": "Updated fundraising.txt"}} + ) + await capture(database, service, fence, changed) + stale = await api.post("/sync/search", json=request) + assert stale.status_code == 200 and stale.json()["items"] == [] + assert stale.json()["excluded_candidates"] > 0 + replacement = await projector.batch( + fence.organization_id, fence.sync_id, "google_drive", destination, log + ) + assert replacement.published == 1 and replacement.failed == 0 + current = await api.post("/sync/search", json=request) + assert current.status_code == 200 + assert len(current.json()["items"]) == 1 + assert current.json()["items"][0]["revision"] == 2 + ctx.organization.id = uuid4() + assert (await api.post("/sync/search", json=request)).status_code == 404 + ctx.organization.id = fence.organization_id + async with database() as db: + await db.execute( + update(SourceConnection) + .where(SourceConnection.id == connection.id) + .values(is_authenticated=False) + ) + await db.commit() + assert (await api.post("/sync/search", json=request)).status_code == 404 + async with database() as db: + await db.execute( + update(SourceConnection) + .where(SourceConnection.id == connection.id) + .values(is_authenticated=True) + ) + await db.commit() + await capture( + database, + service, + fence, + changed.model_copy(update={"kind": "delete", "removal_reason": "provider_deleted"}), + ) + # Old physical Vespa documents still exist: SQL authority must hide them now. + withdrawn = await api.post("/sync/search", json=request) + assert withdrawn.status_code == 200 and withdrawn.json()["items"] == [] + assert withdrawn.json()["excluded_candidates"] > 0 + finally: + await destination.delete_by_sync_id(fence.sync_id) + await destination.close_connection() diff --git a/backend/airweave/domains/entities/canonical/tests/test_record_forest.py b/backend/airweave/domains/entities/canonical/tests/test_record_forest.py new file mode 100644 index 0000000000..2a7c0e1d06 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_record_forest.py @@ -0,0 +1,405 @@ +"""Real PostgreSQL proof of record-forest visibility, independent of provider type depth.""" + +import pytest +from sqlalchemy import select, text + +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalStoreError, content_is_available +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.models.entity import Entity + + +def node(name, parent=None): + return observation(identity=RecordIdentity(record_type="block", native_id=name), parent=parent) + + +async def available(database, ids): + async with database() as db: + return dict( + ( + await db.execute( + select(Entity.id, content_is_available()).where(Entity.id.in_(ids)) + ) + ).all() + ) + + +async def test_nested_loss_revival_requires_fresh_attestation_without_changing_bytes( + database, source +): + service, fence = source + root = node("root") + child = node("child", root.identity) + leaf = node("leaf", child.identity) + result = await capture(database, service, fence, root, child, leaf) + ids = [change.record.id for change in result.changes] + assert all((await available(database, ids)).values()) + await capture( + database, + service, + fence, + root.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + assert not any((await available(database, ids)).values()) + await capture(database, service, fence, root) + assert list((await available(database, ids)).values()).count(True) == 1 + refreshed = await capture(database, service, fence, child) + assert refreshed.changes[0].record.payload == child.payload + assert refreshed.changes[0].record.revision == 2 + assert (await available(database, ids))[ids[2]] is False + await capture(database, service, fence, leaf) + assert all((await available(database, ids)).values()) + async with database() as db: + epochs = list((await db.scalars(select(Entity.visibility_epoch))).all()) + assert epochs == [2, 2, 2] + unchanged = await capture(database, service, fence, child) + assert unchanged.unchanged == 1 + await capture( + database, service, fence, root.model_copy(update={"payload": {"new": "metadata"}}) + ) + assert all((await available(database, ids)).values()) + async with database() as db: + assert (await db.get(Entity, ids[0])).visibility_epoch == 2 + + +async def test_record_cycle_rejected_and_transaction_rolls_back(database, source): + service, fence = source + root = node("root") + child = node("child", root.identity) + result = await capture(database, service, fence, root, child) + with pytest.raises(CanonicalStoreError, match="cycle"): + await capture(database, service, fence, root.model_copy(update={"parent": child.identity})) + assert all((await available(database, [item.record.id for item in result.changes])).values()) + with pytest.raises(CanonicalStoreError, match="cycle"): + await capture( + database, + service, + fence, + root.model_copy( + update={ + "parent": child.identity, + "kind": "delete", + "removal_reason": "provider_deleted", + } + ), + ) + with pytest.raises(CanonicalStoreError, match="parent itself"): + await capture(database, service, fence, root.model_copy(update={"parent": root.identity})) + + +async def test_missing_parent_rejected_and_corrupt_record_cycle_fails_closed(database, source): + service, fence = source + with pytest.raises(CanonicalStoreError, match="must exist"): + await capture( + database, + service, + fence, + node("orphan", RecordIdentity(record_type="block", native_id="missing")), + ) + root = node("root") + child = node("child", root.identity) + result = await capture(database, service, fence, root, child) + async with database() as db: + row = await db.get(Entity, result.changes[0].record.id) + row.parent_record_type = "block" + row.parent_native_id = "child" + row.parent_visibility_epoch = 1 + await db.commit() + async with database() as db: + await db.execute(text("SET LOCAL statement_timeout='2s'")) + assert not any( + (await db.execute(select(content_is_available()).select_from(Entity))).scalars().all() + ) + + +async def test_deep_same_kind_forest_has_no_twenty_level_cutoff(database, source): + service, fence = source + records = [node("0")] + for index in range(1, 35): + records.append(node(str(index), records[-1].identity)) + result = await capture(database, service, fence, *records) + assert all((await available(database, [item.record.id for item in result.changes])).values()) + + +@pytest.mark.parametrize("database", ["forest_upgrade"], indirect=True) +async def test_upgrade_attests_only_existing_available_flat_links(database): + async with database() as db: + rows = list((await db.scalars(select(Entity).where(Entity.record_revision > 0))).all()) + states = dict( + ( + await db.execute( + select(Entity.native_id, content_is_available()).where( + Entity.record_revision > 0 + ) + ) + ).all() + ) + assert states == { + "active-root": True, + "active-child": True, + "withdrawn-root": False, + "withdrawn-child": False, + "hidden-child": False, + "orphan": False, + } + assert next(row for row in rows if row.native_id == "active-child").parent_visibility_epoch == 1 + assert all( + row.source_payload == {"native": row.native_id} and row.record_revision == 1 for row in rows + ) + + +async def test_cleanup_propagates_ancestor_reason_even_before_intermediate_cleanup( + database, source +): + service, fence = source + root = node("root") + child = node("child", root.identity) + leaf = node("leaf", child.identity) + await capture(database, service, fence, root, child, leaf) + await capture( + database, + service, + fence, + root.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + async with database() as db: + removed = await service.reconcile_parents(db, fence) + assert len(removed.capture.changes) == 2 + assert {change.record.removal_reason for change in removed.capture.changes} == { + "access_revoked" + } + + +async def test_reparent_invalidates_descendants_and_failed_batch_rolls_back(database, source): + service, fence = source + first, second = node("first"), node("second") + child = node("child", first.identity) + leaf = node("leaf", child.identity) + result = await capture(database, service, fence, first, second, child, leaf) + leaf_id = result.changes[-1].record.id + moved = child.model_copy(update={"parent": second.identity, "allow_reparent": True}) + with pytest.raises(CanonicalStoreError, match="different parent"): + await capture(database, service, fence, moved.model_copy(update={"allow_reparent": False})) + assert (await available(database, [leaf_id]))[leaf_id] is True + with pytest.raises(CanonicalStoreError): + await capture(database, service, fence, moved, node("bad", node("missing").identity)) + assert (await available(database, [leaf_id]))[leaf_id] is True + await capture(database, service, fence, moved) + replay = await capture( + database, service, fence, moved.model_copy(update={"allow_reparent": False}) + ) + assert replay.unchanged == 1 and replay.changes == () + assert (await available(database, [leaf_id]))[leaf_id] is False + await capture(database, service, fence, leaf) + assert (await available(database, [leaf_id]))[leaf_id] is True + + +@pytest.mark.parametrize("shape,size", [("deep", 96), ("wide", 1024)]) +async def test_real_recursive_query_plan(shape, size, database, source): + import json + + from sqlalchemy.dialects import postgresql + + service, fence = source + records = [node("root")] + for index in range(size): + parent = records[-1] if shape == "deep" else records[0] + records.append(node(str(index), parent.identity)) + for offset in range(0, len(records), 500): + await capture(database, service, fence, *records[offset : offset + 500]) + query = select(Entity.id).where( + Entity.organization_id == fence.organization_id, + Entity.sync_id == fence.sync_id, + content_is_available(), + ) + compiled = query.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}) + async with database() as db: + await db.execute(text("ANALYZE entity")) + plan = await db.scalar(text("EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) " + str(compiled))) + assert plan[0]["Plan"]["Actual Rows"] == size + 1 + assert "Recursive Union" in json.dumps(plan) + print( + json.dumps( + {"forest_shape": shape, "records": size + 1, "execution_ms": plan[0]["Execution Time"]} + ) + ) + + +@pytest.mark.parametrize("withdrawal", ["delete", "access_change"]) +async def test_nested_withdrawal_gates_original_blob_history_search_and_publication( + database, source, withdrawal +): + from types import SimpleNamespace + from unittest.mock import AsyncMock + from uuid import uuid4 + + from airweave.domains.entities.canonical.projection_models import ProjectionLocator + from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore + from airweave.domains.entities.canonical.query import CanonicalQueryService, RecordNotFound + from airweave.domains.entities.canonical.query_models import RecordFilters + from airweave.domains.entities.canonical.query_store import CanonicalQueryStore + from airweave.domains.entities.canonical.requests import BlobReference + from airweave.domains.entities.canonical.tests.helpers import publish_prepared + from airweave.domains.entities.canonical.tests.test_search_visibility import hit + from airweave.domains.search.canonical_visibility import visible_results + from airweave.models.collection import Collection + from airweave.models.source_connection import SourceConnection + from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + service, fence = source + root = node("root").model_copy( + update={"payload": {"accessRole": "owner"}, "descendant_visibility_fields": ("accessRole",)} + ) + child = node("child", root.identity) + leaf = node("leaf", child.identity).model_copy( + update={"blobs": (BlobReference(key="not-read", sha256="a" * 64, size_bytes=1),)} + ) + captured = await capture(database, service, fence, root, child, leaf) + leaf_id = captured.changes[-1].record.id + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="test", embedding_dimensions=3, sparse_embedder="test" + ) + db.add(deployment) + await db.flush() + db.add( + Collection( + name="Test", + readable_id="test", + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + ) + await db.flush() + db.add( + SourceConnection( + name="Test", + short_name="gmail", + organization_id=fence.organization_id, + readable_collection_id="test", + sync_id=fence.sync_id, + is_authenticated=True, + ) + ) + await db.commit() + projection = CanonicalProjectionStore() + async with database() as db: + work = next( + item + for item in await projection.pending(db, fence.organization_id, fence.sync_id) + if item.record.id == leaf_id + ) + generation = uuid4() + assert await publish_prepared(projection, db, work, generation, 1) + locator = ProjectionLocator( + record_id=leaf_id, revision=1, pipeline_version=1, generation=generation, part_index=0 + ) + candidate = hit(fence, locator.encode()) + registry = SimpleNamespace( + get=lambda _: SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("block",)) + ) + ) + async with database() as db: + assert await visible_results(db, fence.organization_id, "test", [candidate], registry) == [ + candidate + ] + await capture( + database, + service, + fence, + root.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}) + if withdrawal == "delete" + else root.model_copy(update={"payload": {"accessRole": "reader"}}), + ) + query = CanonicalQueryService(service.store, CanonicalQueryStore(), "test-signing-key") + storage = AsyncMock() + async with database() as db: + exact = await query.read(db, fence.organization_id, fence.sync_id, leaf_id) + assert exact.content_access == "unavailable" and exact.payload == {} and not exact.blobs + listed = await query.queries.list_records( + db, fence.organization_id, fence.sync_id, RecordFilters(), after_id=None, limit=100 + ) + assert {item.identity.native_id for item in listed} == ( + {"root"} if withdrawal == "access_change" else set() + ) + history = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert all( + item.record.content_access == "unavailable" + for item in history.changes + if withdrawal == "delete" or item.record.identity.native_id != "root" + ) + with pytest.raises(RecordNotFound): + await query.blob( + db, fence.organization_id, fence.sync_id, leaf_id, 1, "a" * 64, storage + ) + assert not storage.mock_calls + assert not await visible_results(db, fence.organization_id, "test", [candidate], registry) + assert not await projection.publish(db, work, generation, 1) + + if withdrawal == "access_change": + await capture(database, service, fence, child) + restored = await capture(database, service, fence, leaf) + assert restored.changes[0].record.id == leaf_id + async with database() as db: + exact = await query.read(db, fence.organization_id, fence.sync_id, leaf_id) + assert exact.content_access == "available" and exact.blobs + + +async def test_access_field_transition_is_atomic_and_not_metadata_revision(database, source): + from airweave.domains.entities.canonical.store import capture_fingerprint + + service, fence = source + root = node("root").model_copy( + update={"payload": {"role": "owner"}, "descendant_visibility_fields": ("role",)} + ) + child = node("child", root.identity) + initial = await capture(database, service, fence, root, child) + root_id, child_id = [item.record.id for item in initial.changes] + assert capture_fingerprint(root) == capture_fingerprint( + root.model_copy(update={"descendant_visibility_fields": ()}) + ) + metadata = root.model_copy(update={"payload": {"role": "owner", "summary": "Renamed"}}) + await capture(database, service, fence, metadata) + async with database() as db: + assert (await db.get(Entity, root_id)).visibility_epoch == 1 + downgrade = metadata.model_copy(update={"payload": {"role": "reader"}}) + with pytest.raises(CanonicalStoreError): + await capture(database, service, fence, downgrade, node("orphan", node("missing").identity)) + assert (await available(database, [child_id]))[child_id] + await capture(database, service, fence, downgrade) + assert not (await available(database, [child_id]))[child_id] + unchanged = await capture(database, service, fence, downgrade) + assert unchanged.unchanged == 1 + async with database() as db: + assert (await db.get(Entity, root_id)).visibility_epoch == 2 + missing = downgrade.model_copy(update={"payload": {}}) + await capture(database, service, fence, missing) + async with database() as db: + assert (await db.get(Entity, root_id)).visibility_epoch == 3 + + +async def test_first_access_context_capture_of_legacy_null_payload(database, source): + service, fence = source + root = node("legacy").model_copy( + update={"payload": {"role": "reader"}, "descendant_visibility_fields": ("role",)} + ) + async with database() as db: + row = Entity( + organization_id=fence.organization_id, + sync_id=fence.sync_id, + sync_job_id=fence.job_id, + entity_id=root.identity.entity_key, + entity_definition_short_name="block", + hash="legacy", + source_payload=None, + ) + db.add(row) + await db.commit() + record_id = row.id + result = await capture(database, service, fence, root) + assert result.changes[0].record.id == record_id + assert result.changes[0].record.revision == 1 + async with database() as db: + row = await db.get(Entity, record_id) + assert row.visibility_epoch == 2 and row.source_payload == {"role": "reader"} diff --git a/backend/airweave/domains/entities/canonical/tests/test_reprojection.py b/backend/airweave/domains/entities/canonical/tests/test_reprojection.py new file mode 100644 index 0000000000..11e9aff798 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_reprojection.py @@ -0,0 +1,100 @@ +"""Real SQL operator scope, dry-run and retry/version fencing.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.reprojection import plan_reprojection +from airweave.domains.entities.canonical.tests.test_owned_search import ( + indexed as search_indexed, # noqa: F401, F811 +) +from airweave.models.sync import Sync + + +async def test_dry_run_then_apply_and_identical_resume(database, search_indexed): # noqa: F811 + fence, _, _ = search_indexed + kwargs = {"expected_version": 2, "target_version": 3} + async with database() as db: + plan = await plan_reprojection(db, fence.organization_id, fence.sync_id, **kwargs) + assert not plan.applied and plan.target_pending_records == 1 + await db.commit() + async with database() as db: + assert await db.scalar(select(Sync.index_pipeline_version)) == 2 + applied = await plan_reprojection( + db, fence.organization_id, fence.sync_id, apply=True, **kwargs + ) + await db.commit() + async with database() as db: + resumed = await plan_reprojection( + db, fence.organization_id, fence.sync_id, apply=True, **kwargs + ) + assert resumed.current_version == 3 and resumed.workflow_id == applied.workflow_id + await db.commit() + async with database() as db: + pending = await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id) + assert len(pending) == 1 and pending[0].pipeline_version == 3 + + +async def test_wrong_scope_and_changed_version_fail(database, search_indexed): # noqa: F811 + fence, _, _ = search_indexed + async with database() as db: + with pytest.raises(ValueError, match="unavailable"): + await plan_reprojection( + db, uuid4(), fence.sync_id, expected_version=2, target_version=3 + ) + await db.rollback() + await db.execute(update(Sync).values(index_pipeline_version=4)) + await db.commit() + async with database() as db: + with pytest.raises(ValueError, match="changed"): + await plan_reprojection( + db, + fence.organization_id, + fence.sync_id, + expected_version=2, + target_version=3, + apply=True, + ) + + +async def test_launch_failure_keeps_queue_and_identical_retry_starts_once( + database, + search_indexed, # noqa: F811 + monkeypatch, +): + from argparse import Namespace + from unittest.mock import AsyncMock + + from airweave.domains.entities.canonical.projection_store import publication_matches + from airweave.models.entity import Entity + from scripts import reproject_canonical + + fence, locator, _ = search_indexed + args = Namespace( + organization=fence.organization_id, sync=fence.sync_id, expect=2, target=3, apply=True + ) + monkeypatch.setattr(reproject_canonical, "AsyncSessionLocal", database) + monkeypatch.setattr( + reproject_canonical, "get_client", AsyncMock(side_effect=RuntimeError("offline")) + ) + with pytest.raises(RuntimeError, match="offline"): + await reproject_canonical.run(args) + async with database() as db: + assert await db.scalar(select(Sync.index_pipeline_version)) == 3 + visible = await db.scalar( + select(Entity.id) + .join(Sync, Sync.id == Entity.sync_id) + .where(publication_matches(locator)) + ) + assert visible is None + client = AsyncMock() + monkeypatch.setattr(reproject_canonical, "get_client", AsyncMock(return_value=client)) + await reproject_canonical.run(args) + assert client.start_workflow.await_count == 1 + assert client.start_workflow.call_args.kwargs["id"] == ( + f"canonical-projection:{fence.organization_id}:{fence.sync_id}" + ) + async with database() as db: + assert await db.scalar(select(Sync.index_pipeline_version)) == 3 diff --git a/backend/airweave/domains/entities/canonical/tests/test_resume_probe.py b/backend/airweave/domains/entities/canonical/tests/test_resume_probe.py new file mode 100644 index 0000000000..a52865796e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_resume_probe.py @@ -0,0 +1,253 @@ +"""Local subprocess verification for the opt-in live harness, with no provider credentials.""" + +import asyncio +import json +import os +import sys +from pathlib import Path + +import pytest +from sqlalchemy import text + +BACKEND = Path(__file__).resolve().parents[5] + + +async def subprocess_script(script, environment): + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + script, + cwd=BACKEND, + env={**os.environ, **environment, "PYTHONPATH": str(BACKEND)}, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), 30) + finally: + if process.returncode is None: + process.kill() + await process.wait() + return process.returncode, stdout.decode(), stderr.decode() + + +PROCESS_SCRIPT = r""" +import asyncio, json, os, sys +from pathlib import Path +sys.path.insert(0, "tests/live") +import conftest +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from airweave.domains.entities.canonical.requests import WriterFence, CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.test_slack_recovery import ( + runner, run, ROOT, HISTORY, REPLIES, +) +from capture_resume import PageResumeProbe, inspect_resume_progress +from provider_lifecycle import BoundedPageSource + +async def main(): + engine = create_async_engine(os.environ["CANONICAL_TEST_DATABASE_URL"], + connect_args={"server_settings": {"search_path": os.environ["TEST_SCHEMA"]}}) + sessions = async_sessionmaker(engine, expire_on_commit=False) + fence = WriterFence.model_validate_json(os.environ["TEST_FENCE"]) + mode = os.environ["TEST_MODE"] + service = CanonicalCaptureService(CanonicalRecordStore()) + from airweave.platform.sources.slack import SlackApiError + retry = mode in ("resume", "withdraw", "expired") + responses = [ROOT, REPLIES] if retry else [ROOT, HISTORY, REPLIES] + if mode == "withdraw": + responses = [ROOT, SlackApiError("not_in_channel")] + if mode == "expired": + responses = [ROOT, SlackApiError("invalid_cursor"), HISTORY, REPLIES] + instance, connector, pipeline = await runner(sessions, (service, fence), responses, + attempt=2 if retry else 1) + counters = {"provider_requests": 0, "records_observed": 0} + get = connector._get + async def counted_get(*args): + counters["provider_requests"] += 1 + return await get(*args) + connector._get = counted_get + probe = PageResumeProbe(sessions, fence.organization_id, fence.sync_id, fence.job_id, + pipeline._attempt.id, Path(os.environ["TEST_TARGET"]), + "resume" if retry else "interrupt", counters) + if mode == "uncommitted": + await probe.before_page(CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(value={"pending_threads": ["1"]})) + raise AssertionError("Uncommitted progress was accepted") + pipeline.page_source = BoundedPageSource(connector, counters, 100, probe) + await run(instance) + evidence = await inspect_resume_progress(sessions, fence.organization_id, fence.sync_id, + fence.job_id, Path(os.environ["TEST_TARGET"])) + print(json.dumps({**evidence, "resumed": probe.resumed_saved_page, + "expired": probe.saved_cursor_expired, **counters}), flush=True) + await engine.dispose() +asyncio.run(main()) +""" + + +async def test_process_loss_after_commit_then_fresh_same_job_resume(database, source, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + environment = { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + "TEST_MODE": "uncommitted", + } + code, stdout, stderr = await subprocess_script(PROCESS_SCRIPT, environment) + assert code != 75 and "Durable interruption state is missing" in stderr + assert not (tmp_path / "target.json").exists() + code, stdout, stderr = await subprocess_script( + PROCESS_SCRIPT, {**environment, "TEST_MODE": "interrupt"} + ) + assert code == 75, stderr + stopped = json.loads(stdout.splitlines()[-1]) + assert stopped["durable_pending_threads_verified"] and stopped["job_status"] == "running" + assert stopped["provider_requests"] == 2 and stopped["records_observed"] == 2 + assert stopped["full_scope_completed"] is False + assert (tmp_path / "target.json").stat().st_mode & 0o077 == 0 + code, stdout, stderr = await subprocess_script( + PROCESS_SCRIPT, {**environment, "TEST_MODE": "resume"} + ) + assert code == 0, stderr + resumed = json.loads(stdout.splitlines()[-1]) + assert resumed == { + "resumed_page_committed": True, + "resumed": True, + "expired": False, + "provider_requests": 2, + "records_observed": 3, + } + + +@pytest.mark.parametrize("mode", ["withdraw", "expired"]) +async def test_withdrawal_or_restarted_sweep_does_not_prove_saved_reply_commit( + database, source, tmp_path, mode +): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + environment = { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + "TEST_MODE": "interrupt", + } + code, _, stderr = await subprocess_script(PROCESS_SCRIPT, environment) + assert code == 75, stderr + code, stdout, stderr = await subprocess_script( + PROCESS_SCRIPT, {**environment, "TEST_MODE": mode} + ) + assert code == 0, stderr + evidence = json.loads(stdout.splitlines()[-1]) + assert evidence["resumed_page_committed"] is False + assert evidence["expired"] is (mode == "expired") + + +BUDGET_SCRIPT = r""" +import asyncio, json, os, sys +from pathlib import Path +sys.path.insert(0, "tests/live") +import conftest +import provider_lifecycle as lifecycle + +async def main(): + snapshots = [] + outputs = [ + {"failed": False, "intentional_interruption": True, + "provider_requests": 2, "records_observed": 5}, + {"failed": False, "full_scope_completed": True, "resumed_saved_page": True, + "provider_requests": 3, "records_observed": 7}, + {"failed": False, "full_scope_completed": True, + "provider_requests": 1, "records_observed": 2}, + ] + killed = [] + class Process: + def __init__(self, index): + self.index = index + self.returncode = (None if (os.environ["TEST_MODE"] == "timeout" or + (os.environ["TEST_MODE"] == "partial" and index == 1)) + else (75 if index == 0 else 0)) + async def communicate(self): + if self.returncode is None: + raise asyncio.TimeoutError() + return json.dumps(outputs[self.index]).encode(), b"" + def kill(self): + killed.append(True) + self.returncode = -9 + async def wait(self): + return self.returncode + async def spawn(*args, **kwargs): + snapshots.append(json.loads(Path(args[2]).read_text())) + return Process(len(snapshots)-1) + lifecycle.asyncio.create_subprocess_exec = spawn + async def evidence(): + return {"resumed_page_committed": True} + result = await lifecycle.run_trials({"provider": "slack", "root": os.environ["TEST_ROOT"], + "timeout": 100, "request_limit": 20, "record_limit": 50}, + evidence) + if os.environ["TEST_MODE"] in ("timeout", "partial"): + assert len(snapshots) == (2 if os.environ["TEST_MODE"] == "partial" else 1) and killed + if os.environ["TEST_MODE"] == "partial": + assert result[-1]["resumed_partial"] is True + assert result[-1]["full_scope_completed"] is False + summary = lifecycle.trial_counter_summary(result) + assert summary["aggregate_counters_complete"] is False + assert summary["aggregate_provider_requests"] is None + assert summary["aggregate_records_observed"] is None + else: + assert [item["request_limit"] for item in snapshots] == [20,18,15] + assert [item["record_limit"] for item in snapshots] == [50,45,38] + assert snapshots[0]["job_id"] == snapshots[1]["job_id"] != snapshots[2]["job_id"] + assert [item["attempt_number"] for item in snapshots] == [1,2,1] + assert 100 >= snapshots[0]["timeout"] >= snapshots[1]["timeout"] >= snapshots[2]["timeout"] + assert lifecycle.trial_counter_summary(result)["aggregate_provider_requests"] == 6 + print("verified") +asyncio.run(main()) +""" + + +async def test_parent_trial_keeps_aggregate_budget_and_marks_killed_usage_unknown(tmp_path): + for mode in ("normal", "timeout", "partial"): + code, stdout, stderr = await subprocess_script( + BUDGET_SCRIPT, + { + "TEST_MODE": mode, + "TEST_ROOT": str(tmp_path), + }, + ) + assert code == 0, stderr + assert stdout.splitlines()[-1] == "verified" + + +async def test_trial_summary_distinguishes_observation_from_full_scope_evidence(): + script = r""" +import sys +sys.path.insert(0, 'tests/live') +import provider_lifecycle as lifecycle +first = dict(failed=False, full_scope_completed=True, payload_revision_digest='same') +second = dict(failed=False, full_scope_completed=False, payload_revision_digest='same', + loaded_durable_checkpoint=True, sync_token_requests=1) +assert lifecycle.trial_read_summary([first, second]) == { + 'saved_cursor_reused': True, 'identical_second_read': True} +for field in ('sync_token_requests', 'resumed_changes_requests', 'resumed_saved_page'): + delta = {**second, 'sync_token_requests': 0, field: 1} + assert lifecycle.trial_read_summary([first, delta])['saved_cursor_reused'] +result = lifecycle.trial_read_summary([first, {**second, 'sync_token_requests': 0}]) +assert not result['saved_cursor_reused'] +result = lifecycle.trial_read_summary([first, {**second, 'saved_cursor_expired': True}]) +assert not result['saved_cursor_reused'] +result = lifecycle.trial_read_summary([first, {**second, 'failed': True}]) +assert not result['identical_second_read'] +result = lifecycle.trial_read_summary( + [first, {**second, 'payload_revision_digest': 'changed'}]) +assert not result['identical_second_read'] +result = lifecycle.trial_read_summary([{'failed': False}, {'failed': False}]) +assert not result['identical_second_read'] +assert not lifecycle.trial_read_summary([])['identical_second_read'] +print('verified') +""" + code, stdout, stderr = await subprocess_script(script, {}) + assert code == 0, stderr + assert stdout.strip().endswith("verified") diff --git a/backend/airweave/domains/entities/canonical/tests/test_retained_text.py b/backend/airweave/domains/entities/canonical/tests/test_retained_text.py new file mode 100644 index 0000000000..75d3f944fe --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_retained_text.py @@ -0,0 +1,289 @@ +"""Real PDF conversion/SQL/blob reads; fixed chunking/embedding, no provider calls.""" + +import hashlib +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import fitz +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import select, update + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.api import deps +from airweave.api.v1.endpoints.records import record_error_response, router +from airweave.db.session import get_db +from airweave.domains.converters.registry import ConverterRegistry +from airweave.domains.embedders.fakes.embedder import FakeDenseEmbedder, FakeSparseEmbedder +from airweave.domains.entities.canonical.projection_gc import ProjectionGCStore +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore, CanonicalStoreError +from airweave.domains.entities.canonical.tests.helpers import bind_projection, capture, observation +from airweave.domains.entities.canonical.text_models import TextArtifact +from airweave.domains.entities.canonical.text_query import CanonicalTextReader +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.sync import Sync +from airweave.platform.destinations.vespa.transformer import EntityTransformer + + +@pytest.fixture +async def publication(database, source, tmp_path): + service, fence = source + binding = await bind_projection(database, fence, "google_drive") + storage = FilesystemBackend(tmp_path) + pdf = fitz.open() + for index in range(3): + page = pdf.new_page() + page.insert_textbox( + fitz.Rect(40, 40, 560, 780), + ( + f"Page content {index}. " + + "Retained complete document words. " * 150 + + "\n# Content\nThis marker belongs to the source, not our metadata." + ), + fontsize=9, + ) + raw = pdf.tobytes() + pdf.close() + digest = hashlib.sha256(raw).hexdigest() + blob = BlobReference( + key=f"canonical/{fence.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(raw), + media_type="application/pdf", + ) + await storage.write_file(blob.key, raw) + parent = observation( + identity=RecordIdentity(record_type="folder", native_id="parent"), + payload={"id": "parent", "name": "Folder"}, + ) + original = observation( + identity=RecordIdentity(record_type="file", native_id="pdf"), + parent=parent.identity, + payload={"id": "pdf", "name": "Complete.pdf", "mimeType": "application/pdf"}, + blobs=(blob,), + ) + await capture(database, service, fence, parent, original) + store = CanonicalProjectionStore() + async with database() as db: + work = next( + item + for item in await store.pending(db, fence.organization_id, fence.sync_id) + if item.record.identity.record_type == "file" + ) + registry = ConverterRegistry() + converter = registry.for_extension(".pdf") + converter.convert_batch = AsyncMock(wraps=converter.convert_batch) + processor = ChunkEmbedProcessor(registry, FakeDenseEmbedder(), FakeSparseEmbedder()) + + async def fixed_chunks(entities, context, runtime): + return processor._multiply_entities( + entities, [[{"text": entity.textual_representation}] for entity in entities], context + ) + + processor._chunk_entities = fixed_chunks + destination = MagicMock(collection_id=binding.collection_id, feed_prepared=AsyncMock()) + destination.prepare_documents = lambda chunks: { + "base_entity": [ + EntityTransformer(collection_id=destination.collection_id).transform(chunk) + for chunk in chunks + ] + } + records = CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-key") + reader = CanonicalTextReader(records, storage) + projector = CanonicalProjector(store, database, processor, storage) + return SimpleNamespace( + service=service, + fence=fence, + storage=storage, + work=work, + reader=reader, + projector=projector, + destination=destination, + converter=converter, + original=original, + parent=parent, + records=records, + blob=blob, + raw=raw, + ) + + +async def test_pdf_full_content_read_is_single_conversion_and_published_with_index( + database, publication +): + p = publication + + async def feed(documents): + async with database() as db: + manifest = await db.scalar( + select(ProjectionGeneration).where( + ProjectionGeneration.record_id == p.work.record.id + ) + ) + assert manifest.text_representations + listed = await p.reader.list( + db, p.fence.organization_id, p.fence.sync_id, p.work.record.id, 1 + ) + assert listed.status == "unavailable" # bytes exist but publication has not happened + + p.destination.feed_prepared.side_effect = feed + assert await p.projector.project_one(p.work, "google_drive", p.destination, MagicMock()) + p.converter.convert_batch.assert_awaited_once() + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: SimpleNamespace( + organization=SimpleNamespace(id=p.fence.organization_id) + ) + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace( + storage_backend=p.storage + ) + app.dependency_overrides[deps.get_canonical_query_service] = lambda: p.records + base = f"/sync/{p.fence.sync_id}/records/{p.work.record.id}/text-representations" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://test" + ) as client: + listed = await client.get(base, params={"revision": 1}) + assert listed.status_code == 200 + descriptor = listed.json()["representations"][0] + assert ( + descriptor["kind"] == "extracted_text" and descriptor["source_anchors"] == "unavailable" + ) + params = {"revision": 1, "generation": descriptor["generation"], "limit": 100000} + result = await client.get(f"{base}/{descriptor['id']}", params=params) + assert result.status_code == 200 + text = result.json()["text"] + assert len(text) > 1000 and "Page content 2" in text and "marker belongs" in text + assert not text.startswith("# Metadata") + assert result.json()["next_offset"] is None + index = await client.get(f"{base}/{descriptor['id']}", params={**params, "view": "index"}) + assert index.json()["text"].startswith("# Metadata") + assert index.json()["text"].endswith(text) + await capture( + database, + p.service, + p.fence, + p.parent.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + ) + denied = await client.get(f"{base}/{descriptor['id']}", params=params) + assert denied.status_code == 404 + assert await p.storage.read_file(p.blob.key) == p.raw + + +async def test_blob_corruption_and_midread_revision_change_fail_closed(database, publication): + from airweave.domains.entities.canonical.query import BlobUnavailable, StaleRecordRevision + + p = publication + assert await p.projector.project_one(p.work, "google_drive", p.destination, MagicMock()) + async with database() as db: + descriptor = ( + await p.reader.list(db, p.fence.organization_id, p.fence.sync_id, p.work.record.id, 1) + ).representations[0] + row = await db.get(ProjectionGeneration, descriptor.generation) + artifact = TextArtifact.model_validate(row.text_representations[0]) + key = artifact.storage_key(p.fence.sync_id, descriptor.generation) + original_bytes = await p.storage.read_file(key) + await p.storage.write_file(key, b"corrupt") + args = ( + p.fence.organization_id, + p.fence.sync_id, + p.work.record.id, + 1, + descriptor.generation, + descriptor.id, + ) + async with database() as db: + with pytest.raises(BlobUnavailable): + await p.reader.read(db, *args) + await p.storage.write_file(key, original_bytes) + read_file = p.storage.read_file + + async def pipeline_changes(path, **kwargs): + content = await read_file(path, **kwargs) + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == p.fence.sync_id).values(index_pipeline_version=2) + ) + await db.commit() + return content + + from airweave.domains.entities.canonical.text_query import TextUnavailable + + p.storage.read_file = pipeline_changes + async with database() as db: + with pytest.raises(TextUnavailable): + await p.reader.read(db, *args) + async with database() as db: + await db.execute( + update(Sync).where(Sync.id == p.fence.sync_id).values(index_pipeline_version=1) + ) + await db.commit() + + async def changing_read(path, **kwargs): + content = await read_file(path, **kwargs) + await capture( + database, + p.service, + p.fence, + p.original.model_copy( + update={"payload": {**p.original.payload, "name": "Changed.pdf"}} + ), + ) + return content + + p.storage.read_file = changing_read + async with database() as db: + with pytest.raises(StaleRecordRevision): + await p.reader.read(db, *args) + + +async def test_abandoned_generation_text_is_in_bounded_repeatable_gc(database, publication): + p = publication + p.destination.feed_prepared.side_effect = RuntimeError("feed unavailable") + with pytest.raises(RuntimeError): + await p.projector.project_one(p.work, "google_drive", p.destination, MagicMock()) + async with database() as db: + row = await db.scalar( + select(ProjectionGeneration).where(ProjectionGeneration.record_id == p.work.record.id) + ) + generation = row.id + now = datetime.now(timezone.utc) + timedelta(hours=2) + gc = ProjectionGCStore() + async with database() as db: + first = await gc.claim(db, generation, now=now, limit=1) + assert len(first.documents) == 1 and not first.artifact_keys + await gc.acknowledge(db, first, now=now) + async with database() as db: + second = await gc.claim(db, generation, now=now, limit=1) + assert not second.documents and len(second.artifact_keys) == 1 + await gc.acknowledge(db, second, now=now, error="StorageUnavailable") + async with database() as db: + retry = await gc.claim(db, generation, now=now + timedelta(minutes=6), limit=1) + assert retry.artifact_keys == second.artifact_keys + for key in retry.artifact_keys: + retained = await p.storage.read_file(key) + await p.storage.delete_file(key) + await gc.acknowledge(db, retry, now=now + timedelta(minutes=6)) + # A timed-out writer can finish after deletion; recurring cleanup retains exact keys. + await p.storage.write_file(retry.artifact_keys[0], retained) + async with database() as db: + repeated = await gc.claim(db, generation, now=now + timedelta(hours=1), limit=100) + assert repeated.artifact_keys == retry.artifact_keys + for key in repeated.artifact_keys: + await p.storage.delete_file(key) + await gc.acknowledge(db, repeated, now=now + timedelta(hours=1)) + assert await p.storage.read_file(p.blob.key) == p.raw diff --git a/backend/airweave/domains/entities/canonical/tests/test_scan_policies.py b/backend/airweave/domains/entities/canonical/tests/test_scan_policies.py new file mode 100644 index 0000000000..054b874129 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_scan_policies.py @@ -0,0 +1,266 @@ +"""Real SQL: incomplete discovery cannot acquire exhaustive deletion authority.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, +) +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitOmission, + CommitScanPage, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.tests.helpers import capture, observation + + +async def start(database, service, fence, policy): + config = CycleConfiguration( + fingerprint="c" * 64, parents={"event": (None,)}, completion_policies={"event": policy} + ) + async with database() as db: + cycle = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=config)) + async with database() as db: + state = await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=CompletedScope(record_type="event"), + cycle_id=cycle.version.cycle_id, + fingerprint=config.fingerprint, + ), + ) + async with database() as db: + result = await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + records=(), + continuation=ScanContinuation(), + final=True, + ), + ) + return result.state, config + + +async def finish(database, service, fence, state): + async with database() as db: + return await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + + +@pytest.mark.parametrize("policy", ["discovery_only", "discovery_with_validation"]) +async def test_incomplete_discovery_preserves_known_record_and_reports_policy( + database, source, policy +): + service, fence = source + original = (await capture(database, service, fence, observation())).changes[0].record + state, config = await start(database, service, fence, policy) + assert state.completion_policy == policy + if policy == "discovery_with_validation": + with pytest.raises(ScanConflict, match="still require"): + await finish(database, service, fence, state) + async with database() as db: + request = CommitOmission( + fence=fence, + state=state, + expected_record=original, + observation=observation(payload={"summary": "Fresh"}), + ) + result = await service.commit_omission(db, request) + assert result.capture.changes[0].record.payload == {"summary": "Fresh"} + with pytest.raises(ScanConflict): + async with database() as db: + await service.commit_omission(db, request) # Lost acknowledgement. + state = result.state + complete = await finish(database, service, fence, state) + assert complete.state.phase == "complete" and complete.capture.changes == () + async with database() as db: + coverage = await capture_coverage(db, fence.organization_id, (fence.sync_id,)) + assert coverage[fence.sync_id].discovery == "incomplete" + assert coverage[fence.sync_id].policies == {"event": policy} + assert await capture_coverage(db, uuid4(), (fence.sync_id,)) == {} + with pytest.raises(CycleConflict): + async with database() as db: + await service.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=config.model_copy( + update={"completion_policies": {"event": "exhaustive"}} + ), + ), + ) + + async with database() as db: + current = await service.read_cycle(db, fence) + async with database() as db: + await service.complete_cycle(db, CompleteCycle(fence=fence, expected=current.version)) + async with database() as db: + completed_coverage = await capture_coverage(db, fence.organization_id, (fence.sync_id,)) + assert completed_coverage[fence.sync_id].phase == "complete" + assert completed_coverage[fence.sync_id].discovery == "incomplete" + + +async def test_exact_validation_restores_tombstone_but_rejects_stale_or_wrong_identity( + database, source +): + service, fence = source + deleted = observation(kind="delete", removal_reason="scope_removed") + original = (await capture(database, service, fence, deleted)).changes[0].record + state, _ = await start(database, service, fence, "discovery_with_validation") + async with database() as db: + missing = await service.scan_missing(db, fence, state) + assert missing[0].id == original.id + for change in [ + observation("other"), + observation(container_id="other"), + observation(kind="delete", removal_reason="absent"), + ]: + with pytest.raises(ScanConflict): + async with database() as db: + await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=original, observation=change + ), + ) + newer = ( + (await capture(database, service, fence, observation(payload={"v": 2}))).changes[0].record + ) + with pytest.raises(ScanConflict): + async with database() as db: + await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=original, observation=observation() + ), + ) + async with database() as db: + result = await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=newer, observation=observation() + ), + ) + assert result.capture.changes[0].record.deleted_at is None + await finish(database, service, fence, result.state) + + +async def test_restore_by_exact_validation_and_resume_after_lost_ack(database, source): + service, fence = source + original = ( + ( + await capture( + database, service, fence, observation(kind="delete", removal_reason="scope_removed") + ) + ) + .changes[0] + .record + ) + state, _ = await start(database, service, fence, "discovery_with_validation") + async with database() as db: + restored = await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=original, observation=observation() + ), + ) + assert restored.capture.changes[0].record.deleted_at is None + assert restored.capture.changes[0].record.content_access == "available" + async with database() as db: + fresh_fence = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + async with database() as db: + resumed = await service.read_scan(db, fresh_fence, state.scope) + assert resumed.version == restored.state.version + complete = await finish(database, service, fresh_fence, resumed) + assert complete.state.phase == "complete" + + +async def test_exhaustive_default_cannot_use_omission_mutation(database, source): + service, fence = source + original = (await capture(database, service, fence, observation())).changes[0].record + state, _ = await start(database, service, fence, "exhaustive") + with pytest.raises(ScanConflict, match="does not accept"): + async with database() as db: + await service.commit_omission( + db, + CommitOmission( + fence=fence, state=state, expected_record=original, observation=observation() + ), + ) + finished = await finish(database, service, fence, state) + assert finished.capture.changes[0].record.deleted_at is not None + + +async def test_driver_retries_failed_exact_read_without_absence_removal(database, source): + from unittest.mock import AsyncMock, Mock + + from airweave.domains.entities.canonical.page_source import CapturePage + from airweave.domains.storage.file_service import FileService + from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver + + service, fence = source + original = (await capture(database, service, fence, observation())).changes[0].record + + class Discovery: + capture_cycle_configuration = CycleConfiguration( + fingerprint="a" * 64, + parents={"event": (None,)}, + completion_policies={"event": "discovery_with_validation"}, + ) + failed = False + + async def capture_page(self, scope, continuation, *, files, parent=None): + return CapturePage(records=(), continuation=ScanContinuation(), final=True) + + async def refresh_known(self, record, *, files): + if not self.failed: + self.failed = True + raise ConnectionError("Synthetic provider unavailable") + assert record.id == original.id + return observation(payload={"fresh": True}) + + provider = Discovery() + driver = CanonicalScanDriver( + service, database, fence, provider, AsyncMock(), AsyncMock(), Mock(spec=FileService) + ) + with pytest.raises(ConnectionError): + await driver.run() + async with database() as db: + pending = await service.read_scan(db, fence, CompletedScope(record_type="event")) + assert pending.phase == "reconciling" + remaining = await service.scan_missing(db, fence, pending) + assert remaining[0].deleted_at is None + await driver.run() + async with database() as db: + completed = await service.read_scan(db, fence, pending.scope) + assert completed.phase == "complete" diff --git a/backend/airweave/domains/entities/canonical/tests/test_scans.py b/backend/airweave/domains/entities/canonical/tests/test_scans.py new file mode 100644 index 0000000000..4ed96de94c --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_scans.py @@ -0,0 +1,275 @@ +"""Actual PostgreSQL recovery and transaction tests, without provider traffic.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import func, select + +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, +) +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitScanPage, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore, StaleWriter +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.entity_change import EntityChange + +SCOPE = CompletedScope(record_type="event") +FINGERPRINT = "a" * 64 + + +async def begin(database, service, fence, *, cycle=None, **kwargs): + if cycle is None: + async with database() as db: + active = await service.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=CycleConfiguration( + fingerprint=FINGERPRINT, root_record_type="event" + ), + ), + ) + cycle = active.version.cycle_id + async with database() as db: + return await service.begin_scan( + db, + BeginScan( + fence=fence, + scope=SCOPE, + cycle_id=cycle or uuid4(), + fingerprint=FINGERPRINT, + **kwargs, + ), + ) + + +async def page(database, service, fence, state, *records, final=False): + async with database() as db: + return await service.commit_scan_page( + db, + CommitScanPage( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + records=records, + continuation=ScanContinuation(value={"next": "page-2"}), + final=final, + ), + ) + + +async def reconcile(database, service, fence, state, limit=250): + async with database() as db: + return await service.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + limit=limit, + ), + ) + + +async def test_resume_new_writer_preserves_prior_page_sightings(database, source): + service, fence = source + await capture(database, service, fence, observation("removed")) + state = await begin(database, service, fence) + first = await page(database, service, fence, state, observation("one")) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + resumed = await begin(database, service, newer, cycle=state.cycle_id) + assert resumed == first.state + with pytest.raises(StaleWriter): + await page(database, service, fence, first.state, observation("stale")) + final = await page(database, service, newer, resumed, observation("two"), final=True) + complete = await reconcile(database, service, newer, final.state) + assert complete.state.phase == "complete" + assert [change.record.identity.native_id for change in complete.capture.changes] == ["removed"] + async with database() as db: + active = ( + await db.scalars(select(Entity.native_id).where(Entity.deleted_at.is_(None))) + ).all() + assert set(active) == {"one", "two"} + + +async def test_page_commit_without_ack_reload_and_stale_cas(database, source): + service, fence = source + state = await begin(database, service, fence) + committed = await page(database, service, fence, state, observation()) + with pytest.raises(ScanConflict): + await page(database, service, fence, state, observation("must-not-appear")) + async with database() as db: + loaded = await service.read_scan(db, fence, SCOPE) + assert loaded == committed.state + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 1 + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 1 + + +async def test_page_failure_rolls_back_records_journal_and_progress(database, source): + service, fence = source + state = await begin(database, service, fence) + + class FailingStore(CanonicalRecordStore): + async def _capture_locked(self, *args, **kwargs): + await super()._capture_locked(*args, **kwargs) + raise RuntimeError("synthetic post-write failure") + + failing = CanonicalCaptureService(FailingStore()) + with pytest.raises(RuntimeError, match="synthetic"): + await page(database, failing, fence, state, observation(), final=True) + async with database() as db: + loaded = await service.read_scan(db, fence, SCOPE) + assert loaded == state + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 0 + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 0 + + +async def test_partial_cannot_reconcile_and_expired_cursor_restarts_sweep(database, source): + service, fence = source + state = await begin(database, service, fence) + partial = await page(database, service, fence, state, observation("prior-page")) + with pytest.raises(ScanConflict, match="fully collected"): + await reconcile(database, service, fence, partial.state) + restarted = await begin( + database, service, fence, cycle=state.cycle_id, expected=partial.state.version, restart=True + ) + assert restarted.version.sweep_id != state.version.sweep_id + assert restarted.continuation.value == {} + with pytest.raises(ScanConflict): + await page(database, service, fence, partial.state, observation("old-reply")) + final = await page(database, service, fence, restarted, observation("now-present"), final=True) + complete = await reconcile(database, service, fence, final.state) + assert [change.record.identity.native_id for change in complete.capture.changes] == [ + "prior-page" + ] + + +async def test_completed_scope_reused_in_cycle_but_next_cycle_refreshes(database, source): + service, fence = source + state = await begin(database, service, fence) + with pytest.raises(CycleConflict): + await begin(database, service, fence, cycle=uuid4(), expected=state.version) + final = await page(database, service, fence, state, final=True) + complete = (await reconcile(database, service, fence, final.state)).state + assert await begin(database, service, fence, cycle=state.cycle_id) == complete + async with database() as db: + active = await service.read_cycle(db, fence) + async with database() as db: + finished = await service.complete_cycle( + db, CompleteCycle(fence=fence, expected=active.version) + ) + async with database() as db: + following = await service.begin_cycle( + db, + BeginCycle( + fence=fence, configuration=finished.configuration, expected=finished.version + ), + ) + next_cycle = await begin( + database, service, fence, cycle=following.version.cycle_id, expected=complete.version + ) + assert next_cycle.cycle_id != complete.cycle_id + assert next_cycle.version.sweep_id != complete.version.sweep_id + assert next_cycle.phase == "collecting" + with pytest.raises(CycleConflict): + await begin( + database, + service, + fence, + cycle=complete.cycle_id, + expected=next_cycle.version, + restart=True, + ) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(CaptureScan)) == 1 + + +async def test_reconcile_is_bounded_and_resumes_after_worker_loss(database, source): + service, fence = source + await capture(database, service, fence, *(observation(str(n)) for n in range(3))) + state = await begin(database, service, fence) + final = await page(database, service, fence, state, final=True) + first = await reconcile(database, service, fence, final.state, limit=1) + assert first.state.phase == "reconciling" and len(first.capture.changes) == 1 + resumed = await begin(database, service, fence, cycle=state.cycle_id) + assert resumed == first.state + last = await reconcile(database, service, fence, resumed) + assert last.state.phase == "complete" and len(last.capture.changes) == 2 + with pytest.raises(ScanConflict): + await page(database, service, fence, last.state, observation()) + + +async def test_scope_identity_null_and_empty_are_distinct_and_pages_cannot_cross(database, source): + service, fence = source + state = await begin(database, service, fence) + with pytest.raises(ScanConflict, match="outside"): + await page(database, service, fence, state, observation(container_id="")) + from airweave.domains.entities.canonical.scan_store import scope_key + + assert scope_key(SCOPE) != scope_key(CompletedScope(record_type="event", container_id="")) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(CaptureScan)) == 1 + assert await db.scalar(select(func.count()).select_from(Entity)) == 0 + + +def test_progress_is_bounded_and_unknown_fields_rejected(): + with pytest.raises(ValidationError, match="64 KiB"): + ScanContinuation(value={"cursor": "x" * 65536}) + with pytest.raises(ValidationError): + ScanContinuation(value={}, range_start="unsupported") + + +async def test_concurrent_same_page_has_one_commit_and_one_cas_conflict(database, source): + import asyncio + + service, fence = source + state = await begin(database, service, fence) + outcomes = await asyncio.gather( + page(database, service, fence, state, observation("one")), + page(database, service, fence, state, observation("two")), + return_exceptions=True, + ) + assert sum(isinstance(result, ScanConflict) for result in outcomes) == 1 + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Entity)) == 1 + assert await db.scalar(select(func.count()).select_from(EntityChange)) == 1 + assert (await db.scalar(select(CaptureScan))).revision == 2 + + +@pytest.mark.parametrize("database", ["legacy"], indirect=True) +async def test_scan_migration_preserves_existing_legacy_records(database, source): + service, fence = source + state = await begin(database, service, fence) + final = await page(database, service, fence, state, observation(), final=True) + assert (await reconcile(database, service, fence, final.state)).state.phase == "complete" + async with database() as db: + legacy = await db.scalar(select(Entity).where(Entity.record_revision == 0)) + assert legacy is not None and legacy.entity_id == "legacy-native-key" diff --git a/backend/airweave/domains/entities/canonical/tests/test_search_metadata.py b/backend/airweave/domains/entities/canonical/tests/test_search_metadata.py new file mode 100644 index 0000000000..e2848a4b93 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_search_metadata.py @@ -0,0 +1,44 @@ +"""Canonical dates keep source ownership and microsecond precision through feed.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.search_metadata import stamp_search_metadata +from airweave.domains.entities.canonical.tests.helpers import observation +from airweave.platform.destinations.vespa.transformer import EntityTransformer +from airweave.platform.entities._base import AirweaveSystemMetadata +from airweave.platform.entities.slack import SlackChannelEntity + + +def test_metadata_never_uses_mapper_dates(): + native = datetime(2026, 1, 1, microsecond=1, tzinfo=timezone.utc) + record = SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=observation().identity, + revision=1, + payload={}, + payload_schema_version=1, + capture_hash="hash", + content_hash=None, + completeness="complete", + observed_at=native, + source_created_at=native, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + entity = SlackChannelEntity(channel_id="one", title="One", purpose="", topic="", breadcrumbs=[]) + entity.created_at = entity.updated_at = datetime(2030, 1, 1, tzinfo=timezone.utc) + entity.airweave_system_metadata = AirweaveSystemMetadata() + stamp_search_metadata(entity.airweave_system_metadata, record) + fields = EntityTransformer()._build_system_metadata(entity) + assert fields["source_created_us"] == 1767225600000001 + assert fields["source_created_known"] == 1 + assert fields["source_updated_us"] is None + assert fields["source_updated_known"] == 0 + assert fields["canonical_record_type"] == "event" diff --git a/backend/airweave/domains/entities/canonical/tests/test_search_sessions.py b/backend/airweave/domains/entities/canonical/tests/test_search_sessions.py new file mode 100644 index 0000000000..c322b93416 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_search_sessions.py @@ -0,0 +1,277 @@ +"""Real PostgreSQL: search owns short reads, even when remote work stops or fails.""" + +# Imported pytest fixtures are requested by parameter name. +# ruff: noqa: F811 + +import asyncio +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from cryptography.fernet import Fernet +from fastapi import Request +from fastapi_auth0 import Auth0User +from sqlalchemy import select, text, update +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from airweave.adapters.cache.fake import FakeContextCache +from airweave.adapters.rate_limiter.fake import FakeRateLimiter +from airweave.api import deps +from airweave.core import credentials +from airweave.core.config import AuthMode, settings +from airweave.core.shared_models import AuthMethod +from airweave.db import session as db_session +from airweave.domains.entities.canonical.tests.test_owned_search import ( # noqa: F401 + http_search, + indexed, +) +from airweave.domains.entities.canonical.tests.test_search_visibility import hit +from airweave.domains.search.types import SearchResults +from airweave.models.api_key import APIKey +from airweave.models.collection import Collection +from airweave.models.entity import Entity +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync +from airweave.models.user import User +from airweave.models.user_organization import UserOrganization + + +@pytest.fixture +async def single_connection(database): + async with database() as db: + schema = await db.scalar(text("SELECT current_schema()")) + url = db.bind.url + engine = create_async_engine( + url, + pool_size=1, + max_overflow=0, + pool_timeout=0.25, + connect_args={"server_settings": {"search_path": schema}}, + ) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +async def connection_is_free(sessions): + # A second checkout of this one-connection pool would fail if search held it. + async with sessions() as probe: + assert await probe.scalar(text("SELECT 1")) == 1 + + +@pytest.mark.parametrize("stage", ["embedding", "vespa"]) +async def test_waiting_search_releases_connection_and_cancels_cleanly( + indexed, http_search, single_connection, stage, monkeypatch +): + fence, _, _ = indexed + client, vector, _, executor, _ = http_search + client._transport.app.dependency_overrides[deps.get_search_session_factory] = ( + lambda: single_connection + ) + entered, release = asyncio.Event(), asyncio.Event() + target, method = ( + (executor._sparse_embedder, "embed") if stage == "embedding" else (vector, "execute_query") + ) + original = getattr(target, method) + + async def paused(*args, **kwargs): + entered.set() + await release.wait() + return await original(*args, **kwargs) + + monkeypatch.setattr(target, method, paused) + task = asyncio.create_task( + client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + }, + ) + ) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + await connection_is_free(single_connection) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + await connection_is_free(single_connection) + + +async def test_revoked_source_during_vespa_cannot_return_cached_hit( + indexed, http_search, single_connection, monkeypatch +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + client._transport.app.dependency_overrides[deps.get_search_session_factory] = ( + lambda: single_connection + ) + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + original = vector.execute_query + + async def revoke_then_return(query): + # This mutation also proves the network phase has released the only connection. + async with single_connection() as db: + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + return await original(query) + + monkeypatch.setattr(vector, "execute_query", revoke_then_return) + response = await client.post( + "/sync/search", + json={ + "query": "budget", + "sync_ids": [str(fence.sync_id)], + "mode": "keyword", + }, + ) + assert response.status_code == 404 + assert "Private original text" not in response.text + await connection_is_free(single_connection) + + +@pytest.mark.parametrize("outcome", ["failure", "cancellation"]) +async def test_read_phase_releases_acquired_connection( + indexed, http_search, single_connection, outcome +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + app = client._transport.app + app.dependency_overrides[deps.get_search_session_factory] = lambda: single_connection + service = app.dependency_overrides[deps.get_container]().owned_search + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + entered = asyncio.Event() + + async def interrupt_after_sql(db, *args): + await db.scalar(select(Entity.id)) + entered.set() + if outcome == "failure": + raise RuntimeError("synthetic enrichment failure") + await asyncio.Event().wait() + + service._enrich = interrupt_after_sql + task = asyncio.create_task( + client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + ) + waiting = asyncio.create_task(entered.wait()) + try: + await asyncio.wait({task, waiting}, timeout=5, return_when=asyncio.FIRST_COMPLETED) + if not entered.is_set(): + if task.done(): + response = await task # Surface an early exception instead of hiding it as timeout. + pytest.fail(f"Search returned HTTP {response.status_code} before enrichment") + pytest.fail("Search did not reach enrichment within five seconds") + if outcome == "cancellation": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(RuntimeError, match="synthetic enrichment failure"): + await task + finally: + # Failed setup must not leave a request borrowing from the fixture's pool. + waiting.cancel() + task.cancel() + await asyncio.gather(waiting, task, return_exceptions=True) + await connection_is_free(single_connection) + + +@pytest.mark.parametrize("mode", [AuthMode.API_KEY, AuthMode.AUTH0]) +async def test_search_auth_closes_own_transaction_without_committing_auth0_activity( + source, single_connection, mode, monkeypatch +): + _, fence = source + monkeypatch.setattr(settings, "AUTH_MODE", mode) + monkeypatch.setattr(settings, "ENCRYPTION_KEY", Fernet.generate_key().decode()) + # No external analytics in this real SQL authentication proof. + monkeypatch.setattr("airweave.api.context_resolver.analytics.identify_user", Mock()) + old_active = datetime(2020, 1, 1) + async with single_connection() as db: + user = User(email="search@example.com", auth0_id="auth0|search", last_active_at=old_active) + db.add(user) + await db.flush() + user_id = user.id + db.add( + UserOrganization( + user_id=user.id, + organization_id=fence.organization_id, + role="owner", + is_primary=True, + ) + ) + db.add( + APIKey( + organization_id=fence.organization_id, + encrypted_key=credentials.encrypt({"key": "synthetic-owned-search-key"}), + expiration_date=datetime.now(timezone.utc).replace(tzinfo=None) + + timedelta(hours=1), + ) + ) + await db.commit() + monkeypatch.setattr(db_session, "AsyncSessionLocal", single_connection) + assert deps.get_search_session_factory() is single_connection + auth0_user = Auth0User.model_construct(id="auth0|search", email="search@example.com") + ctx = await deps.get_owned_search_context( + request=Request( + { + "type": "http", + "method": "POST", + "path": "/sync/search", + "headers": [], + "client": ("127.0.0.1", 1), + "scheme": "http", + } + ), + sessions=deps.get_search_session_factory(), + x_api_key="synthetic-owned-search-key" if mode == AuthMode.API_KEY else None, + x_organization_id=str(fence.organization_id), + auth0_user=auth0_user if mode == AuthMode.AUTH0 else None, + cache=FakeContextCache(), + rate_limiter=FakeRateLimiter(), + ) + assert ctx.organization.id == fence.organization_id + assert ctx.auth_method == (AuthMethod.API_KEY if mode == AuthMode.API_KEY else AuthMethod.AUTH0) + async with single_connection() as db: + assert await db.scalar(select(User.last_active_at).where(User.id == user_id)) == old_active + if mode == AuthMode.AUTH0: + assert ctx.user.last_active_at > old_active + await connection_is_free(single_connection) + + +@pytest.mark.parametrize("change", ["collection", "pipeline"]) +async def test_scope_identity_change_during_retrieval_rejects_response( + indexed, http_search, single_connection, monkeypatch, change +): + fence, locator, _ = indexed + client, vector, _, _, _ = http_search + client._transport.app.dependency_overrides[deps.get_search_session_factory] = ( + lambda: single_connection + ) + vector.seed_results(SearchResults(results=[hit(fence, locator.encode())])) + original = vector.execute_query + + async def change_then_return(query): + async with single_connection() as db: + if change == "collection": + await db.execute(update(Collection).values(id=uuid4())) + else: + await db.execute(update(Sync).values(index_pipeline_version=1)) + await db.commit() + return await original(query) + + monkeypatch.setattr(vector, "execute_query", change_then_return) + response = await client.post( + "/sync/search", + json={"query": "budget", "sync_ids": [str(fence.sync_id)], "mode": "keyword"}, + ) + assert response.status_code == 404 + assert "Private original text" not in response.text + await connection_is_free(single_connection) diff --git a/backend/airweave/domains/entities/canonical/tests/test_search_visibility.py b/backend/airweave/domains/entities/canonical/tests/test_search_visibility.py new file mode 100644 index 0000000000..a48cdb9fc7 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_search_visibility.py @@ -0,0 +1,109 @@ +"""Stale index content cannot survive authoritative changes or cross-account scope.""" + +from types import SimpleNamespace +from uuid import uuid4 + +from sqlalchemy import update + +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.tests.helpers import capture, observation, publish_prepared +from airweave.domains.search.canonical_visibility import visible_results +from airweave.domains.search.types.results import ( + SearchAccessControl, + SearchResult, + SearchSystemMetadata, +) +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +def hit(fence, locator): + return SearchResult( + entity_id="index-chunk", + name="Synthetic", + relevance_score=1, + breadcrumbs=[], + textual_representation="Private original text", + airweave_system_metadata=SearchSystemMetadata( + source_name="gmail", + entity_type="message", + sync_id=str(fence.sync_id), + chunk_index=0, + original_entity_id=locator, + ), + access=SearchAccessControl(), + web_url="https://example.test", + raw_source_fields={}, + ) + + +async def test_search_rejects_stale_foreign_malformed_and_legacy_publications(database, source): + service, fence = source + await capture(database, service, fence, observation()) + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="test", embedding_dimensions=3, sparse_embedder="test" + ) + db.add(deployment) + await db.flush() + db.add( + Collection( + name="Test", + readable_id="test", + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + ) + await db.flush() + db.add( + SourceConnection( + name="Gmail", + short_name="gmail", + organization_id=fence.organization_id, + readable_collection_id="test", + sync_id=fence.sync_id, + is_authenticated=True, + ) + ) + await db.commit() + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1) + locator = ProjectionLocator( + record_id=work.record.id, + revision=1, + pipeline_version=1, + generation=generation, + part_index=0, + ) + valid = hit(fence, locator.encode()) + registry = SimpleNamespace( + get=lambda _: SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("message",)) + ) + ) + wrong_revision = locator.model_copy(update={"revision": 2}) + inputs = [ + valid, + hit(fence, wrong_revision.encode()), + hit(fence, "native-id"), + hit(fence, "canonical:invalid"), + ] + async with database() as db: + assert await visible_results(db, fence.organization_id, "test", inputs, registry) == [valid] + assert await visible_results(db, uuid4(), "test", inputs, registry) == [] + assert await visible_results(db, fence.organization_id, "other", inputs, registry) == [] + async with database() as db: + await db.execute(update(SourceConnection).values(is_authenticated=False)) + await db.commit() + assert await visible_results(db, fence.organization_id, "test", inputs, registry) == [] + await db.execute(update(SourceConnection).values(is_authenticated=True)) + await db.commit() + await capture(database, service, fence, observation(payload={"id": "one", "summary": "Edited"})) + async with database() as db: + assert await visible_results(db, fence.organization_id, "test", inputs, registry) == [] diff --git a/backend/airweave/domains/entities/canonical/tests/test_service_key_http.py b/backend/airweave/domains/entities/canonical/tests/test_service_key_http.py new file mode 100644 index 0000000000..48361749a7 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_service_key_http.py @@ -0,0 +1,98 @@ +"""Real persisted-key authentication through the original-record HTTP boundary.""" + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +from cryptography.fernet import Fernet +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete, update + +from airweave.adapters.rate_limiter.null import NullRateLimiter +from airweave.api.v1.endpoints.records import record_error_response, router +from airweave.core import container as container_mod +from airweave.core import credentials +from airweave.core.config import AuthMode, settings +from airweave.core.datetime_utils import utc_now_naive +from airweave.db.session import get_db +from airweave.domains.entities.canonical.requests import CaptureBatch +from airweave.domains.entities.canonical.store import CanonicalStoreError +from airweave.domains.entities.canonical.tests.helpers import observation +from airweave.models import Organization +from airweave.models.api_key import APIKey + + +async def test_persisted_keys_scope_expiry_and_revocation_over_http(database, source, monkeypatch): + """Only Redis cache/rate limiting are substituted; auth and SQL stay real.""" + monkeypatch.setattr(settings, "AUTH_MODE", AuthMode.API_KEY) + monkeypatch.setattr(settings, "ENCRYPTION_KEY", Fernet.generate_key().decode()) + cache = SimpleNamespace( + get_organization=AsyncMock(return_value=None), set_organization=AsyncMock() + ) + monkeypatch.setattr( + container_mod, + "container", + SimpleNamespace(context_cache=cache, rate_limiter=NullRateLimiter()), + ) + capture, fence = source + other_org = uuid4() + owner_key, foreign_key = uuid4().hex, uuid4().hex + async with database() as db: + db.add(Organization(id=other_org, name="Other synthetic owner")) + await db.flush() + keys = [ + APIKey( + organization_id=org, + encrypted_key=credentials.encrypt({"key": key}), + expiration_date=utc_now_naive() + timedelta(days=1), + created_by_email="synthetic@example.com", + modified_by_email="synthetic@example.com", + ) + for org, key in ((fence.organization_id, owner_key), (other_org, foreign_key)) + ] + db.add_all(keys) + await db.commit() + result = await capture.capture(db, CaptureBatch(fence=fence, records=(observation("one"),))) + app = FastAPI() + app.include_router(router, prefix="/sync") + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + base = f"/sync/{fence.sync_id}/records" + paths = (base, f"{base}/{result.changes[0].record.id}", base + "/changes") + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + for path in paths: + assert (await client.get(path)).status_code == 401 + assert (await client.get(path, headers={"X-API-Key": "invalid"})).status_code == 403 + valid = await client.get(path, headers={"X-API-Key": owner_key}) + assert valid.status_code == 200, valid.text + assert (await client.get(path, headers={"X-API-Key": foreign_key})).status_code == 404 + assert ( + await client.get( + path, + headers={ + "X-API-Key": foreign_key, + "X-Organization-ID": str(fence.organization_id), + }, + ) + ).status_code == 403 + async with database() as db: + await db.execute( + update(APIKey) + .where(APIKey.id == keys[0].id) + .values( + expiration_date=utc_now_naive() - timedelta(seconds=1), + ) + ) + await db.commit() + assert (await client.get(base, headers={"X-API-Key": owner_key})).status_code == 403 + async with database() as db: + await db.execute(delete(APIKey).where(APIKey.id == keys[1].id)) + await db.commit() + assert (await client.get(base, headers={"X-API-Key": foreign_key})).status_code == 403 diff --git a/backend/airweave/domains/entities/canonical/tests/test_sheets_capture.py b/backend/airweave/domains/entities/canonical/tests/test_sheets_capture.py new file mode 100644 index 0000000000..59ea3fa124 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_sheets_capture.py @@ -0,0 +1,232 @@ +"""Real SQL publication/revision/access gates over native spreadsheet blobs.""" + +from unittest.mock import MagicMock + +import httpx +import pytest + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.entities.canonical.projection_mappers import _drive +from airweave.domains.entities.canonical.query import ( + CanonicalQueryService, + RecordNotFound, + StaleRecordRevision, +) +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.file_service import FileService +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_content import capture_file_content +from airweave.platform.sources.records.sheets_manifest import SHEETS_MIME, GridBounds + + +async def test_sheets_exact_revision_projection_and_removal( + database, source, tmp_path, monkeypatch +): + service, fence = source + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", lambda _: str(tmp_path / "tmp") + ) + files = FileService(fence.job_id, FilesystemBackend(tmp_path / "store"), sync_id=fence.sync_id) + grid = { + "spreadsheetId": "book", + "sheets": [ + { + "properties": { + "sheetId": 0, + "title": "Sheet", + "gridProperties": {"rowCount": 2, "columnCount": 2}, + } + } + ], + } + + async def latest(*args, **kwargs): + return {"version": "7"} + + def response(request): + if request.url.path.endswith("/export"): + return httpx.Response(200, content=b"exact-export") + if "ranges" in request.url.params: + return httpx.Response( + 200, + json={ + "spreadsheetId": "book", + "sheets": [ + { + **grid["sheets"][0], + "data": [ + { + "rowData": [ + { + "values": [ + { + "formattedValue": "42", + "userEnteredValue": {"formulaValue": "=6*7"}, + } + ] + } + ] + } + ], + } + ], + }, + ) + return httpx.Response(200, json=grid) + + original = file_record({"id": "book", "version": "7", "mimeType": SHEETS_MIME}) + async with httpx.AsyncClient(transport=httpx.MockTransport(response)) as client: + retained = await capture_file_content( + original, + files=files, + get=latest, + client=client, + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + capture_native_sheets=True, + ) + record = (await capture(database, service, fence, retained)).changes[0].record + query = CanonicalQueryService( + CanonicalRecordStore(), CanonicalQueryStore(), "synthetic-signing" + ) + async with database() as db: + read = await query.spreadsheet( + db, + fence.organization_id, + fence.sync_id, + record.id, + record.revision, + files.storage, + GridBounds(sheet_id=0, start_row=0, end_row=2, start_column=0, end_column=2), + ) + assert len(read.cells) == 1 + assert read.cells[0].value["userEnteredValue"] == {"formulaValue": "=6*7"} + assert read.grid_status == "complete" and not read.missing + with pytest.raises(StaleRecordRevision): + await query.spreadsheet( + db, + fence.organization_id, + fence.sync_id, + record.id, + record.revision + 1, + files.storage, + ) + entities = (await _drive(record, files.storage, tmp_path / "projection")).entities + assert len(entities) == 1 + from pathlib import Path + + assert "42" in Path(entities[0].local_path).read_text() + assert "=6*7" not in Path(entities[0].local_path).read_text() + monkeypatch.setattr( + "airweave.platform.sources.records.google_sheets_content.MAX_GRID_REQUESTS", 0 + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(response)) as client: + partial = await capture_file_content( + original, + files=files, + get=latest, + client=client, + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + capture_native_sheets=True, + ) + newer = (await capture(database, service, fence, partial)).changes[0].record + assert newer.id == record.id and newer.revision == record.revision + 1 + projection = await _drive(newer, files.storage, tmp_path / "fallback") + fallback = projection.entities + assert len(projection.parts) == 2 + assert projection.parts[1].entity is None + assert projection.parts[1].part.key == "/native/sheets/0/rows/0:2/columns/0:2" + assert newer.completeness == "partial" + assert len(fallback) == 1 + assert Path(fallback[0].local_path).read_bytes() == b"exact-export" + assert fallback[0].file_type == "xlsx" + async with database() as db: + native_partial = await query.spreadsheet( + db, fence.organization_id, fence.sync_id, newer.id, newer.revision, files.storage + ) + assert native_partial.grid_status == "partial" and len(native_partial.missing) == 1 + assert not native_partial.captured and not native_partial.cells + await capture( + database, + service, + fence, + original.model_copy(update={"kind": "delete", "removal_reason": "scope_removed"}), + ) + async with database() as db: + with pytest.raises(RecordNotFound): + await query.spreadsheet( + db, fence.organization_id, fence.sync_id, record.id, record.revision, files.storage + ) + + +async def test_failed_native_tile_retries_same_file_without_advancing_checkpoint( + database, source, tmp_path, monkeypatch +): + from contextlib import aclosing + + from sqlalchemy import select + + from airweave.domains.entities.canonical.tests import test_drive_recovery as recovery + from airweave.domains.entities.canonical.tests.test_workspace_capture import NativeDocs + from airweave.models.entity import Entity + from airweave.platform.configs.config import GoogleDriveConfig + + class NativeSheets(NativeDocs): + async def handle(self, request): + if request.url.host == "sheets.googleapis.com": + value = { + "spreadsheetId": "doc", + "sheets": [ + { + "properties": { + "sheetId": 0, + "title": "Sheet", + "gridProperties": {"rowCount": 1, "columnCount": 1}, + } + } + ], + } + if "ranges" in request.url.params and self.fail: + raise ConnectionError("interrupted native tile") + return httpx.Response(200, json=value) + response = await super().handle(request) + if request.url.path.endswith("/files/doc"): + return httpx.Response( + 200, json={"id": "doc", "version": "7", "mimeType": SHEETS_MIME} + ) + return response + + monkeypatch.setattr( + recovery, + "GoogleDriveConfig", + lambda **kwargs: GoogleDriveConfig(capture_native_sheets=True, **kwargs), + ) + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", lambda _: str(tmp_path / "tmp") + ) + service, fence = source + files = FileService(fence.job_id, FilesystemBackend(tmp_path / "store"), sync_id=fence.sync_id) + native = NativeSheets() + pipeline, ctx, runtime, client = await recovery.setup(database, source, native) + pipeline.files = files + async with aclosing(client): + with pytest.raises(ConnectionError, match="interrupted native tile"): + await recovery.run(pipeline, ctx, runtime) + async with database() as db: + assert not (await db.scalars(select(Entity))).all() + cycle = await service.read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint is None + native.fail = False + pipeline, ctx, runtime, client = await recovery.setup(database, source, native, attempt=2) + pipeline.files = files + async with aclosing(client): + await recovery.run(pipeline, ctx, runtime) + async with database() as db: + records = (await db.scalars(select(Entity))).all() + assert len(records) == 1 + cycle = await service.read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint.checkpoint.value == {"page_token": "end"} diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_file_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_slack_file_recovery.py new file mode 100644 index 0000000000..7afbc97f97 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_file_recovery.py @@ -0,0 +1,346 @@ +"""Actual child frontier commits/retry with synthetic Slack HTTP and original bytes.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.entities.canonical.page_source import ( + InvalidScanContinuation, + RequiredScopeAccessLost, +) +from airweave.domains.entities.canonical.requests import CaptureBatch, RecordIdentity +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.file_service import FileService +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackPrincipal, SlackSource + +ROOT = {"channels": [{"id": "C1", "name": "Synthetic"}]} +MESSAGE = { + "ts": "1", + "text": "Original message", + "files": [ + { + "id": f"F{i}", + "size": 3, + "mimetype": "text/plain", + "url_private": f"https://files.slack.com/F{i}", + } + for i in range(1, 4) + ], +} + + +async def attempt( + database, source, storage, calls, *, number, fail_last=False, responses=None, connector_out=None +): + service, fence = source + ctx, _, runtime, bus = components(database, source) + connector = SlackSource( + auth=StaticTokenProvider("fixture"), logger=MagicMock(), http_client=MagicMock() + ) + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=True + ) + connector._verified_principal = SlackPrincipal(ok=True, team_id="T1", user_id="U1") + # A fresh message page attests its complete inventory in the same SQL commit. + # A retry with no fresh owner page must still perform exact owner refresh. + defaults = [ROOT, {"messages": [MESSAGE]}] + connector._get = AsyncMock(side_effect=responses if responses is not None else defaults) + if connector_out is not None: + connector_out.append(connector) + files = FileService(uuid4(), storage, sync_id=fence.sync_id) + + async def download(url, *args, **kwargs): + identity = url.rsplit("/", 1)[-1] + calls.append(identity) + if fail_last and identity == "F3": + raise ConnectionError("Synthetic last-file failure") + return await files.store_canonical_blob(b"abc", media_type="text/plain") + + files.capture_canonical_url = download + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if number == 1 else uuid4(), number=number), + connector.canonical_container_parents, + page_source=connector, + files=files, + ) + runtime.source = connector + runtime.canonical_capture = pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + try: + await run(instance) + finally: + await files.cleanup_sync_directory(MagicMock()) + return pipeline, connector + + +async def test_last_file_retry_preserves_committed_children_and_inventory_hides_old_bytes( + database, source, tmp_path +): + service, fence = source + storage = FilesystemBackend(tmp_path) + calls = [] + with pytest.raises(ConnectionError, match="last-file"): + await attempt(database, source, storage, calls, number=1, fail_last=True) + assert calls == ["F1", "F2", "F3"] + async with database() as db: + rows = (await db.scalars(select(Entity).where(Entity.sync_id == fence.sync_id))).all() + assert {row.native_id for row in rows} == {"C1", "1", "F1", "F2"} + message = next(row for row in rows if row.native_id == "1") + assert message.source_payload == MESSAGE and message.payload_schema_version == 2 + pipeline, connector = await attempt(database, source, storage, calls, number=2) + assert calls == ["F1", "F2", "F3", "F3"] + assert connector._get.await_count == 2 + async with database() as db: + rows = (await db.scalars(select(Entity).where(Entity.sync_id == fence.sync_id))).all() + children = [row for row in rows if row.entity_definition_short_name == "file"] + assert len(children) == 3 and all(row.record_revision == 1 for row in children) + current = connector._capture_message( + {**MESSAGE, "files": [MESSAGE["files"][2]]}, "C1" + ).model_copy( + update={ + "payload_schema_version": 2, + "descendant_visibility_fields": ("files",), + "parent": RecordIdentity(record_type="channel", native_id="C1"), + "completeness": "complete", + } + ) + await service.capture(db, CaptureBatch(fence=pipeline._writer(), records=(current,))) + async with database() as db: + for row in children: + stored = await service.store.read(db, fence.organization_id, fence.sync_id, row.id) + assert stored.content_access == "unavailable" + + +async def test_omitted_accessible_message_does_not_remove_retained_file_children( + database, source, tmp_path +): + from airweave.models.sync_job import SyncJob + + service, fence = source + storage = FilesystemBackend(tmp_path) + await attempt(database, source, storage, [], number=1) + new_job = uuid4() + async with database() as db: + (await db.get(SyncJob, fence.job_id)).status = "completed" + db.add( + SyncJob( + id=new_job, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + following = (service, fence.model_copy(update={"job_id": new_job, "attempt_id": uuid4()})) + connectors = [] + with pytest.raises(ValueError, match="omitted an accessible prior message"): + await attempt( + database, + following, + storage, + [], + number=1, + responses=[ROOT, {"messages": []}, {"messages": [MESSAGE]}], + connector_out=connectors, + ) + call = connectors[0]._get.call_args_list[-1] + assert call.args[0].endswith("conversations.history") + assert call.args[1] == { + "channel": "C1", + "oldest": "1", + "latest": "1", + "inclusive": "true", + "limit": 1, + } + async with database() as db: + rows = (await db.scalars(select(Entity).where(Entity.sync_id == fence.sync_id))).all() + assert len(rows) == 5 + for row in rows: + stored = await service.store.read(db, fence.organization_id, fence.sync_id, row.id) + assert stored.deleted_at is None and stored.content_access == "available" + if row.entity_definition_short_name == "file": + assert stored.blobs + + +@pytest.mark.parametrize("repeat_owner", [False, True]) +async def test_files_progress_before_history_finishes_and_resume_both_cursors( + database, source, tmp_path, repeat_owner +): + storage = FilesystemBackend(tmp_path) + downloads = [] + histories = [] + owner_reads = [] + interrupt = True + + async def respond(url, params): + if url.endswith("conversations.list"): + return ROOT + if "oldest" in params: + owner_reads.append(params["oldest"]) + return {"messages": [MESSAGE]} + cursor = params.get("cursor") + histories.append(cursor) + if cursor is None: + return {"messages": [MESSAGE], "response_metadata": {"next_cursor": "H2"}} + if cursor == "H2": + if interrupt: + raise asyncio.CancelledError() + return { + "messages": [MESSAGE] if repeat_owner else [], + "response_metadata": {"next_cursor": "H3"}, + } + assert cursor == "H3" + return {"messages": []} + + with pytest.raises(asyncio.CancelledError): + await attempt(database, source, storage, downloads, number=1, responses=respond) + assert downloads == ["F1"] + assert owner_reads == [] + async with database() as db: + scans = (await db.scalars(select(CaptureScan))).all() + history = next(row for row in scans if row.record_type == "message") + files = next(row for row in scans if row.record_type == "file") + assert history.phase == files.phase == "collecting" + assert history.continuation["history_cursor"] == "H2" + sweeps = (history.sweep_id, files.sweep_id) + assert "canonical_checkpoint" not in (await db.scalar(select(SyncCursor))).cursor_data + + interrupt = False + await attempt(database, source, storage, downloads, number=2, responses=respond) + assert downloads == ["F1", "F2", "F3"] + assert histories == [None, "H2", "H2", "H3"] + assert owner_reads == ([] if repeat_owner else ["1"]) + async with database() as db: + scans = (await db.scalars(select(CaptureScan))).all() + history = next(row for row in scans if row.record_type == "message") + files = next(row for row in scans if row.record_type == "file") + assert (history.sweep_id, files.sweep_id) == sweeps + assert history.phase == files.phase == "complete" + rows = (await db.scalars(select(Entity))).all() + assert next(row for row in rows if row.native_id == "1").source_payload == MESSAGE + assert all(row.record_revision == 1 for row in rows if row.native_id.startswith("F")) + cursor = (await db.scalar(select(SyncCursor))).cursor_data + assert cursor["canonical_cycle"]["phase"] == "complete" + + +async def test_child_cursor_restart_bound_survives_interleaved_turns( + database, source, tmp_path, monkeypatch +): + original = SlackSource.capture_page + file_calls = 0 + + async def capture(connector, scope, continuation, **kwargs): + nonlocal file_calls + if scope.record_type == "file": + file_calls += 1 + if file_calls != 2: + raise InvalidScanContinuation("Synthetic child cursor expiry") + return await original(connector, scope, continuation, **kwargs) + + monkeypatch.setattr(SlackSource, "capture_page", capture) + + async def respond(url, params): + if url.endswith("conversations.list"): + return ROOT + if "oldest" in params: + return {"messages": [MESSAGE]} + return { + "messages": [MESSAGE] if "cursor" not in params else [], + "response_metadata": {"next_cursor": "H3" if "cursor" in params else "H2"}, + } + + downloads = [] + with pytest.raises(InvalidScanContinuation, match="child cursor"): + await attempt( + database, source, FilesystemBackend(tmp_path), downloads, number=1, responses=respond + ) + assert file_calls == 3 and downloads == ["F1"] + async with database() as db: + cursor = (await db.scalar(select(SyncCursor))).cursor_data + assert "canonical_checkpoint" not in cursor + assert cursor["canonical_cycle"]["phase"] == "active" + + +async def test_required_child_access_loss_does_not_withdraw_enclosing_channel( + database, source, tmp_path, monkeypatch +): + original = SlackSource.capture_page + + async def capture(connector, scope, continuation, **kwargs): + if scope.record_type == "file": + # Exercise the shared driver contract; Slack does not currently emit this subtype. + raise RequiredScopeAccessLost("Synthetic required child access loss") + return await original(connector, scope, continuation, **kwargs) + + monkeypatch.setattr(SlackSource, "capture_page", capture) + with pytest.raises(RequiredScopeAccessLost, match="required child"): + await attempt(database, source, FilesystemBackend(tmp_path), [], number=1) + service, fence = source + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + assert {row.native_id for row in rows} == {"C1", "1"} + for row in rows: + stored = await service.store.read(db, fence.organization_id, fence.sync_id, row.id) + expected = "available" if row.native_id == "C1" else "unavailable" + assert stored.content_access == expected + assert "canonical_checkpoint" not in (await db.scalar(select(SyncCursor))).cursor_data + + +async def test_fresh_empty_inventory_reconciles_old_files_without_exact_owner_call( + database, source, tmp_path +): + from airweave.models.sync_job import SyncJob + + service, fence = source + storage = FilesystemBackend(tmp_path) + downloads = [] + await attempt(database, source, storage, downloads, number=1) + assert downloads == ["F1", "F2", "F3"] + job = uuid4() + async with database() as db: + (await db.get(SyncJob, fence.job_id)).status = "completed" + db.add( + SyncJob( + id=job, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + following = (service, fence.model_copy(update={"job_id": job, "attempt_id": uuid4()})) + pipeline, connector = await attempt( + database, + following, + storage, + downloads, + number=1, + responses=[ROOT, {"messages": [{**MESSAGE, "files": []}]}], + ) + assert connector._get.await_count == 2 + assert downloads == ["F1", "F2", "F3"] + async with database() as db: + children = ( + await db.scalars(select(Entity).where(Entity.entity_definition_short_name == "file")) + ).all() + assert len(children) == 3 and all(child.deleted_at is not None for child in children) + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "file")) + assert scan.phase == "complete" + assert scan.parent_verified_attempt_id == pipeline._writer().attempt_id diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_identity_writes.py b/backend/airweave/domains/entities/canonical/tests/test_slack_identity_writes.py new file mode 100644 index 0000000000..390d79a886 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_identity_writes.py @@ -0,0 +1,37 @@ +"""A different native Slack user must never begin source capture.""" + +from unittest.mock import MagicMock + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackSource + + +async def test_wrong_user_leaves_existing_cursor_and_originals_unchanged(database, source): + async with database() as db: + before = [(row.id, row.cursor_data) for row in (await db.scalars(select(SyncCursor))).all()] + calls = [] + + def profile(request): + calls.append(request.url.path) + return httpx.Response(200, json={"ok": True, "team_id": "T1", "user_id": "U2"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(profile)) as client: + with pytest.raises(ValueError, match="does not match"): + await SlackSource.create( + auth=StaticTokenProvider("same-broker"), + logger=MagicMock(), + http_client=client, + config=SlackConfig(expected_team_id="T1", expected_user_id="U1"), + ) + assert calls == ["/api/auth.test"] + async with database() as db: + assert (await db.scalars(select(Entity))).all() == [] + after = [(row.id, row.cursor_data) for row in (await db.scalars(select(SyncCursor))).all()] + assert after == before diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_limited_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_slack_limited_recovery.py new file mode 100644 index 0000000000..b9e4d93826 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_limited_recovery.py @@ -0,0 +1,41 @@ +"""Retention-limited history cannot erase prior originals or publish a new baseline.""" + +import pytest + +from airweave.domains.entities.canonical.tests.test_mixed_scopes import next_job +from airweave.domains.entities.canonical.tests.test_slack_recovery import ROOT, run, runner, saved +from airweave.domains.sources.exceptions import SourceError + + +async def test_limited_final_history_preserves_prior_originals_and_committed_progress( + database, source +): + baseline, _, _ = await runner( + database, source, [ROOT, {"messages": [{"ts": "1", "text": "old"}]}] + ) + await run(baseline) + _, before, _ = await saved(database) + source = await next_job(database, source) + refresh, _, _ = await runner( + database, + source, + [ + ROOT, + { + "messages": [{"ts": "3", "text": "new"}], + "response_metadata": {"next_cursor": "older"}, + }, + {"messages": [{"ts": "2", "text": "limited page"}], "is_limited": True}, + ], + ) + with pytest.raises(SourceError, match="remains incomplete"): + await run(refresh) + rows, after, scans = await saved(database) + assert {row.native_id for row in rows} == {"C1", "1", "3"} + assert all(row.deleted_at is None for row in rows) + assert after["canonical_checkpoint"] == before["canonical_checkpoint"] + assert after["canonical_cycle"]["phase"] != "complete" + current = next(scan for scan in scans if scan.record_type == "message") + assert current.phase == "collecting" + assert current.continuation["history_cursor"] == "older" + assert current.continuation["history_done"] is False diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_membership.py b/backend/airweave/domains/entities/canonical/tests/test_slack_membership.py new file mode 100644 index 0000000000..5efad75762 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_membership.py @@ -0,0 +1,90 @@ +"""Lost membership hides records captured before the first durable checkpoint.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackApiError, SlackSource + +pytestmark = pytest.mark.integration + + +@pytest.mark.parametrize("listing_fails", [False, True]) +@pytest.mark.parametrize("capture_files", [False, True]) +async def test_first_completed_membership_hides_prior_partial_channel( + database, source, listing_fails, capture_files +): + service, fence = source + parent = RecordIdentity(record_type="channel", native_id="lost-channel") + seeded = await capture( + database, + service, + fence, + observation(identity=parent), + observation( + identity=RecordIdentity( + record_type="message", native_id="1", container_id="lost-channel" + ), + parent=parent, + ), + ) + child_id = seeded.changes[1].record.id + # The failed prior capture saved records but no channel_ids checkpoint. + ctx, _, runtime, bus = components(database, source) + connector = SlackSource( + auth=StaticTokenProvider("fixture"), logger=MagicMock(), http_client=MagicMock() + ) + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=capture_files + ) + connector._get = AsyncMock(return_value={"ok": True, "team_id": "T1", "user_id": "U1"}) + await connector.validate() + connector._get = AsyncMock( + side_effect=( + ConnectionError("membership page failed") + if listing_fails + else [{"channels": []}, SlackApiError("channel_not_found")] + ), + ) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id, number=fence.attempt_number), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + await pipeline.start(ctx) + + async def scan(): + await pipeline.run_scans(ctx, runtime, AsyncMock()) + + if listing_fails: + with pytest.raises(ConnectionError, match="membership page failed"): + await scan() + else: + await scan() + await pipeline.cleanup_orphaned_entities(ctx, runtime) + async with database() as db: + record = await service.store.read(db, fence.organization_id, fence.sync_id, child_id) + if listing_fails: + assert record.content_access == "available" and record.payload + return + assert record.content_access == "unavailable" + assert record.payload == {} and record.blobs == () + parent_record = await service.store.read( + db, fence.organization_id, fence.sync_id, seeded.changes[0].record.id + ) + assert parent_record.removal_reason == "scope_removed" + assert parent_record.content_access == "unavailable" and parent_record.payload == {} + changes = await service.store.changes(db, fence.organization_id, fence.sync_id) + assert all(change.record.payload == {} for change in changes.changes) diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_projection.py b/backend/airweave/domains/entities/canonical/tests/test_slack_projection.py new file mode 100644 index 0000000000..b6eec492eb --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_projection.py @@ -0,0 +1,379 @@ +"""Retained Slack file projection: no provider calls or live authorization claims.""" + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.blob_materializer import BlobIntegrityError +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.platform.entities.slack import SlackAttachmentEntity + + +def message(files): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type="message", native_id="1.000001", container_id="C1"), + revision=1, + payload={"ts": "1.000001", "text": "Intact message", "files": files}, + payload_schema_version=1, + capture_hash="capture", + content_hash=None, + completeness="partial", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +def blob(record, data, index): + digest = hashlib.sha256(data).hexdigest() + return BlobReference( + key=f"canonical/{record.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(data), + source_path=f"/files/{index}", + ) + + +@pytest.mark.asyncio +async def test_body_retained_pdf_duplicate_bytes_and_unavailable_file(): + record = message( + [ + {"id": "F1", "name": "one.pdf", "mimetype": "application/pdf"}, + {"id": "F2", "name": "two.pdf", "mimetype": "application/pdf"}, + {"id": "F3", "name": "clip.mp4", "mimetype": "video/mp4"}, + ] + ) + content = b"%PDF-1.4 retained fixture, converter not exercised" + record = record.model_copy( + update={"blobs": (blob(record, content, 0), blob(record, content, 1))} + ) + before = record.model_dump() + storage = AsyncMock() + storage.read_file.return_value = content + async with map_record(record, "slack", storage) as mapped: + assert [part.part.key for part in mapped.parts] == ["body", "file:F1", "file:F2", "file:F3"] + assert mapped.parts[0].entity.text == "Intact message" + assert mapped.parts[3].entity is None + first, second = mapped.parts[1].entity, mapped.parts[2].entity + assert isinstance(first, SlackAttachmentEntity) + assert first.entity_id != second.entity_id + assert first.local_path == second.local_path + assert Path(first.local_path).read_bytes() == content + assert mapped.parts[1].part.media_type == "application/pdf" + assert mapped.parts[1].part.extension == ".pdf" + assert record.model_dump() == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "files", [[{"id": "F1"}, {"id": "F1"}], [{"name": "missing-id"}], "bad-list"] +) +async def test_ambiguous_or_malformed_files_never_become_complete(files): + storage = AsyncMock() + with pytest.raises(ValueError, match="Slack"): + async with map_record(message(files), "slack", storage): + pass + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_wrong_blob_owner_stale_path_and_corrupt_bytes_fail_before_conversion(): + record = message([{"id": "F1", "name": "safe.pdf"}]) + valid = blob(record, b"expected", 0) + storage = AsyncMock() + for bad in ( + valid.model_copy(update={"key": f"canonical/{uuid4()}/blobs/sha256/{valid.sha256}"}), + valid.model_copy(update={"source_path": "/files/9"}), + ): + with pytest.raises(ValueError): + async with map_record(record.model_copy(update={"blobs": (bad,)}), "slack", storage): + pass + storage.read_file.assert_not_called() + storage.read_file.return_value = b"wrong" + with pytest.raises(BlobIntegrityError): + async with map_record(record.model_copy(update={"blobs": (valid,)}), "slack", storage): + pass + with pytest.raises(ValueError, match="lacks a retained file"): + async with map_record( + record.model_copy(update={"completeness": "complete"}), "slack", storage + ): + pass + + +async def test_slack_pdf_publication_records_partial_extraction(database, source, tmp_path): + """Real PDF conversion/SQL publication, with synthetic embedding and feed only.""" + import fitz + from sqlalchemy import select + + from airweave.adapters.storage.filesystem import FilesystemBackend + from airweave.core.logging import logger + from airweave.domains.entities.canonical.projection_store import current_extraction + from airweave.domains.entities.canonical.tests.helpers import ( + bind_projection, + capture, + observation, + ) + from airweave.domains.entities.canonical.tests.test_extraction_coverage import ( + destination, + projector, + ) + from airweave.domains.storage.file_service import FileService + from airweave.models import Entity + + service, fence = source + binding = await bind_projection(database, fence, "slack") + storage = FilesystemBackend(tmp_path) + pdf = fitz.open() + page = pdf.new_page() + page.insert_text( + (40, 40), "Retained Slack PDF with useful searchable investment discussion. " * 4 + ) + content = pdf.tobytes() + pdf.close() + files = FileService(uuid4(), storage, sync_id=fence.sync_id) + retained = await files.store_canonical_blob(content, media_type="application/pdf") + item = observation( + identity=RecordIdentity(record_type="message", native_id="1.000001", container_id="C1"), + payload={ + "ts": "1.000001", + "text": "Intact Slack discussion.", + "files": [ + {"id": "F1", "name": "discussion.pdf", "mimetype": "application/pdf"}, + {"id": "F2", "name": "missing.pdf", "mimetype": "application/pdf"}, + ], + }, + completeness="partial", + blobs=(retained.model_copy(update={"source_path": "/files/0"}),), + ) + await capture(database, service, fence, item) + result = await projector(database, storage).batch( + fence.organization_id, fence.sync_id, "slack", destination(binding.collection_id), logger + ) + assert result.published == 1 and result.failed == 0 + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == fence.sync_id)) + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + assert coverage.status == "partial" + assert [part.outcome for part in coverage.parts] == [ + "indexed", + "indexed", + "unavailable_original", + ] + assert coverage.parts[1].key == "file:F1" + assert row.completeness == "partial" + old_revision = row.record_revision + + # Switch one retained inline original to explicit child ownership. The parent + # revision invalidates its previous PDF publication before children publish. + from airweave.domains.entities.canonical.requests import parent_container_key + from airweave.domains.entities.canonical.slack_files import SlackFileManifest, SlackFileOutcome + + parent = item.identity + children = [] + for index, native in enumerate(item.payload["files"]): + manifest = SlackFileManifest( + files=( + SlackFileOutcome( + index=0, + native_id=native["id"], + outcome="captured" if index == 0 else "unavailable", + reason=None if index == 0 else "access_denied", + ), + ) + ) + evidence = await files.store_canonical_blob( + manifest.model_dump_json().encode(), media_type="application/json" + ) + refs = (evidence.model_copy(update={"role": "representation_manifest"}),) + if index == 0: + refs += (retained.model_copy(update={"source_path": ""}),) + children.append( + observation( + identity=RecordIdentity( + record_type="file", + native_id=native["id"], + container_id=parent_container_key(parent), + ), + parent=parent, + payload=native, + completeness="complete" if index == 0 else "partial", + blobs=refs, + ) + ) + await capture( + database, + service, + fence, + item.model_copy( + update={ + "payload_schema_version": 2, + "blobs": (), + "completeness": "complete", + "descendant_visibility_fields": ("files",), + } + ), + *children, + ) + async with database() as db: + parent_row = await db.scalar(select(Entity).where(Entity.native_id == parent.native_id)) + assert parent_row.record_revision > old_revision + assert parent_row.indexed_revision != parent_row.record_revision + result = await projector(database, storage).batch( + fence.organization_id, fence.sync_id, "slack", destination(binding.collection_id), logger + ) + assert result.published == 3 and result.failed == 0 + async with database() as db: + rows = (await db.scalars(select(Entity).where(Entity.sync_id == fence.sync_id))).all() + coverage_by_id = { + row.native_id: await current_extraction( + db, fence.organization_id, fence.sync_id, row.id, row.record_revision + ) + for row in rows + } + assert [part.key for part in coverage_by_id[parent.native_id].parts] == ["body"] + assert [part.outcome for part in coverage_by_id["F1"].parts] == ["indexed"] + assert [part.outcome for part in coverage_by_id["F2"].parts] == ["unavailable_original"] + + +@pytest.mark.asyncio +async def test_manifest_enrichment_requires_exact_native_identity_and_blob_evidence(): + """Slack Connect stubs retain their native JSON; acquisition evidence supplies format.""" + import json + + record = message([{"id": "F1", "file_access": "check_file_info"}, {"id": "F2"}]) + content = b"retained PDF fixture" + original = blob(record, content, 0) + payload = { + "version": 1, + "files": [ + { + "index": 0, + "native_id": "F1", + "outcome": "captured", + "file": {"id": "F1", "name": "verified.pdf", "mimetype": "application/pdf"}, + }, + {"index": 1, "native_id": "F2", "outcome": "unavailable", "reason": "access_denied"}, + ], + } + + async def run(value, *, include_original=True): + data = json.dumps(value).encode() + evidence = blob(record, data, 9).model_copy( + update={"source_path": None, "role": "representation_manifest"} + ) + source = record.model_copy( + update={"blobs": ((original,) if include_original else ()) + (evidence,)} + ) + storage = AsyncMock() + storage.read_file.side_effect = lambda key, **_: data if key == evidence.key else content + async with map_record(source, "slack", storage) as mapped: + assert mapped.parts[1].entity.filename == "verified.pdf" + assert mapped.parts[1].part.media_type == "application/pdf" + assert mapped.parts[2].entity is None + assert source.payload == record.payload + + await run(payload) + with pytest.raises(ValueError, match="contradicts"): + await run(payload, include_original=False) + payload["files"][0].update(outcome="unavailable", reason="access_denied") + with pytest.raises(ValueError, match="contradicts"): + await run(payload) + payload["files"][0].update(outcome="captured", reason=None) + payload["files"][1]["native_id"] = "F3" + with pytest.raises(ValueError, match="native message identities"): + await run(payload) + + +@pytest.mark.asyncio +async def test_child_owned_message_never_indexes_inline_attachments(): + source = message([{"id": "F1", "name": "one.pdf"}]).model_copy( + update={"payload_schema_version": 2, "completeness": "complete"} + ) + storage = AsyncMock() + async with map_record(source, "slack", storage) as mapped: + assert [part.part.key for part in mapped.parts] == ["body"] + storage.read_file.assert_not_called() + with pytest.raises(ValueError, match="cannot retain inline"): + async with map_record( + source.model_copy(update={"blobs": (blob(source, b"x", 0),)}), "slack", storage + ): + pass + + +@pytest.mark.asyncio +async def test_child_file_retained_and_unavailable_have_one_extraction_owner(): + import json + + from airweave.domains.entities.canonical.requests import parent_container_key + + parent = message([]).identity + source = message([]).model_copy( + update={ + "identity": RecordIdentity( + record_type="file", native_id="F1", container_id=parent_container_key(parent) + ), + "parent": parent, + "payload": {"id": "F1", "name": "one.pdf", "mimetype": "application/pdf"}, + } + ) + storage = AsyncMock() + for captured in (True, False): + content = b"fixture PDF bytes" + original = blob(source, content, 0).model_copy(update={"source_path": ""}) + data = json.dumps( + { + "version": 1, + "files": [ + { + "index": 0, + "native_id": "F1", + "outcome": "captured" if captured else "unavailable", + "reason": None if captured else "access_denied", + } + ], + } + ).encode() + manifest = blob(source, data, 0).model_copy( + update={"source_path": None, "role": "representation_manifest"} + ) + current = source.model_copy( + update={"blobs": (manifest, original) if captured else (manifest,)} + ) + contents = {manifest.key: data, original.key: content} + storage.read_file.side_effect = lambda key, contents=contents, **_: contents[key] + async with map_record(current, "slack", storage) as mapped: + assert len(mapped.parts) == 1 and mapped.parts[0].part.key == "file:F1" + assert (mapped.parts[0].entity is not None) == captured + with pytest.raises(ValueError, match="exact message parent"): + async with map_record(current.model_copy(update={"parent": None}), "slack", storage): + pass + if captured: + with pytest.raises(ValueError, match="root original"): + async with map_record( + current.model_copy( + update={ + "blobs": ( + manifest, + original.model_copy(update={"source_path": "/files/0"}), + ) + } + ), + "slack", + storage, + ): + pass diff --git a/backend/airweave/domains/entities/canonical/tests/test_slack_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_slack_recovery.py new file mode 100644 index 0000000000..081d306af0 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_slack_recovery.py @@ -0,0 +1,615 @@ +"""Actual Slack page adapter plus orchestrator/capture transactions; synthetic HTTP only.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync_cursor import SyncCursor +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackApiError, SlackSource + +ROOT = {"channels": [{"id": "C1", "name": "Synthetic channel"}]} +MESSAGE = {"ts": "1", "reply_count": 1, "text": "Synthetic parent"} +HISTORY = {"messages": [MESSAGE]} +REPLIES = {"messages": [MESSAGE, {"ts": "1.1", "thread_ts": "1", "text": "Synthetic reply"}]} + + +async def runner(database, source, responses, attempt=1, service=None, *, capture_files=False): + original_service, fence = source + service = service or original_service + ctx, _, runtime, bus = components(database, source) + connector = SlackSource( + auth=StaticTokenProvider("fixture"), logger=MagicMock(), http_client=MagicMock() + ) + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=capture_files + ) + connector._get = AsyncMock(return_value={"ok": True, "team_id": "T1", "user_id": "U1"}) + await connector.validate() + connector._get = AsyncMock(side_effect=responses) + pipeline = CanonicalCapturePipeline( + service, + database, + bus, + connector.canonical_record_types, + CaptureAttempt(id=fence.attempt_id if attempt == 1 else uuid4(), number=attempt), + connector.canonical_container_parents, + page_source=connector, + files=MagicMock(), + ) + runtime.source = connector + runtime.canonical_capture = pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + return instance, connector, pipeline + + +async def run(instance, *, checkpoint=True): + await instance._start_sync() + await instance._process_entities() + await instance._cleanup_orphaned_entities_if_needed() + if checkpoint: + await instance._save_cursor_data() + + +async def saved(database): + async with database() as db: + rows = (await db.scalars(select(Entity))).all() + cursor = (await db.scalar(select(SyncCursor))).cursor_data + scans = (await db.scalars(select(CaptureScan))).all() + return rows, cursor, scans + + +@pytest.mark.parametrize("capture_files", [False, True]) +async def test_cancel_after_history_resumes_pending_reply_without_repeating_history( + database, source, capture_files +): + first, _, _ = await runner( + database, source, [ROOT, HISTORY, asyncio.CancelledError()], capture_files=capture_files + ) + with pytest.raises(asyncio.CancelledError): + await run(first) + rows, cursor, scans = await saved(database) + assert {row.native_id for row in rows} == {"C1", "1"} + assert "canonical_checkpoint" not in cursor + message_scan = next(scan for scan in scans if scan.record_type == "message") + assert message_scan.continuation["pending_threads"] == ["1"] + second, connector, _ = await runner( + database, source, [ROOT, REPLIES], attempt=2, capture_files=capture_files + ) + await run(second) + operations = [call.args[0].split("/")[-1] for call in connector._get.call_args_list] + assert operations[:2] == ["conversations.list", "conversations.replies"] + # Both attachment owners were freshly returned by the reply page; its SQL + # commit attests empty inventories without a redundant exact lookup. + assert len(operations) == 2 + rows, cursor, _ = await saved(database) + assert {row.native_id for row in rows} == {"C1", "1", "1.1"} + assert next(row for row in rows if row.native_id == "1").record_revision == 1 + assert cursor["canonical_cycle"]["phase"] == "complete" + + +async def test_lost_page_ack_reloads_committed_pending_threads(database, source): + class LostAck(CanonicalCaptureService): + async def commit_scan_page(self, db, request): + result = await super().commit_scan_page(db, request) + if request.scope.record_type == "message": + raise ConnectionError("synthetic lost commit acknowledgement") + return result + + first, connector, _ = await runner( + database, source, [ROOT, HISTORY], service=LostAck(source[0].store) + ) + with pytest.raises(ConnectionError, match="acknowledgement"): + await run(first) + assert connector._get.await_count == 2 + second, connector, _ = await runner(database, source, [ROOT, REPLIES], attempt=2) + await run(second) + assert connector._get.call_args_list[-1].args[0].endswith("conversations.replies") + + +async def test_expired_reply_cursor_restarts_whole_scope_and_never_partial_reconcile( + database, source +): + first_reply = { + "messages": [{"ts": "1.old", "thread_ts": "1"}], + "response_metadata": {"next_cursor": "expired"}, + } + first, _, _ = await runner( + database, source, [ROOT, HISTORY, first_reply, asyncio.CancelledError()] + ) + with pytest.raises(asyncio.CancelledError): + await run(first) + rows, _, scans = await saved(database) + assert next(row for row in rows if row.native_id == "1.old").deleted_at is None + old_sweep = next(scan for scan in scans if scan.record_type == "message").sweep_id + second, connector, _ = await runner( + database, source, [ROOT, SlackApiError("invalid_cursor"), HISTORY, REPLIES], attempt=2 + ) + await run(second) + calls = connector._get.call_args_list + assert calls[1].args[1]["cursor"] == "expired" + assert calls[2].args[0].endswith("conversations.history") and "cursor" not in calls[2].args[1] + rows, _, scans = await saved(database) + assert next(row for row in rows if row.native_id == "1.old").deleted_at is not None + assert next(scan for scan in scans if scan.record_type == "message").sweep_id != old_sweep + + +@pytest.mark.parametrize("error_code", ["invalid_cursor", "thread_not_found"]) +async def test_repeated_invalid_cursor_fails_without_checkpoint_or_absence( + database, source, error_code +): + first, _, _ = await runner( + database, + source, + [ROOT, HISTORY, SlackApiError(error_code), HISTORY, SlackApiError(error_code)], + ) + from airweave.domains.entities.canonical.page_source import InvalidScanContinuation + + with pytest.raises(InvalidScanContinuation): + await run(first) + rows, cursor, _ = await saved(database) + assert all(row.deleted_at is None for row in rows) + assert "canonical_checkpoint" not in cursor + + +@pytest.mark.parametrize("accessible", [False, True]) +async def test_membership_loss_on_retry_hides_pending_children_only_after_confirmation( + database, source, accessible +): + first, _, _ = await runner(database, source, [ROOT, HISTORY, asyncio.CancelledError()]) + with pytest.raises(asyncio.CancelledError): + await run(first) + responses = [ + {"channels": []}, + {"channel": {"id": "C1"}} if accessible else SlackApiError("channel_not_found"), + ] + second, connector, pipeline = await runner(database, source, responses, attempt=2) + if accessible: + with pytest.raises(ValueError, match="omitted"): + await run(second) + else: + await run(second) + assert connector._get.await_count == 2 + rows, cursor, _ = await saved(database) + child = next(row for row in rows if row.native_id == "1") + async with database() as db: + value = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, child.id + ) + assert value.content_access == ("available" if accessible else "unavailable") + assert cursor["canonical_cycle"]["phase"] == ("active" if accessible else "complete") + + +async def test_crash_after_scans_then_lost_final_ack_same_job_does_not_start_new_cycle( + database, source +): + first, _, _ = await runner(database, source, [ROOT, HISTORY, REPLIES]) + await run(first, checkpoint=False) + _, cursor, _ = await saved(database) + cycle_id = cursor["canonical_cycle"]["version"]["cycle_id"] + second, connector, _ = await runner(database, source, [ROOT], attempt=2) + await run(second) + assert connector._get.await_count == 1 + third, connector, _ = await runner(database, source, [], attempt=3) + await run(third) + assert connector._get.await_count == 0 + _, cursor, _ = await saved(database) + assert cursor["canonical_cycle"]["version"]["cycle_id"] == cycle_id + + +async def test_new_job_starts_fresh_cycle_without_overriding_successful_old_job(database, source): + from airweave.domains.entities.canonical.store import StaleWriter + from airweave.models.sync_job import SyncJob + + first, _, _ = await runner(database, source, [ROOT, HISTORY, REPLIES]) + await run(first) + _, cursor, _ = await saved(database) + prior_cycle = cursor["canonical_cycle"]["version"]["cycle_id"] + service, fence = source + new_job_id = uuid4() + async with database() as db: + old_job = await db.get(SyncJob, fence.job_id) + old_job.status = "completed" + db.add( + SyncJob( + id=new_job_id, + sync_id=fence.sync_id, + organization_id=fence.organization_id, + status="running", + ) + ) + await db.commit() + stale, _, _ = await runner(database, source, [], attempt=2) + with pytest.raises(StaleWriter, match="active job"): + await run(stale) + new_source = (service, fence.model_copy(update={"job_id": new_job_id, "attempt_id": uuid4()})) + new, connector, _ = await runner(database, new_source, [ROOT, HISTORY, REPLIES]) + await run(new) + assert connector._get.await_count == 3 + _, cursor, _ = await saved(database) + assert cursor["canonical_cycle"]["version"]["cycle_id"] != prior_cycle + assert cursor["canonical_cycle"]["completed_job_id"] == str(new_job_id) + async with database() as db: + assert (await db.get(SyncJob, fence.job_id)).status == "completed" + + +async def test_access_lost_during_reply_withdraws_root_and_children(database, source): + first, _, _ = await runner(database, source, [ROOT, HISTORY, SlackApiError("not_in_channel")]) + await run(first) + rows, cursor, _ = await saved(database) + assert cursor["canonical_cycle"]["phase"] == "complete" + assert all(row.removal_reason == "access_revoked" for row in rows) + async with database() as db: + for row in rows: + value = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, row.id + ) + assert value.content_access == "unavailable" and value.payload == {} + + +@pytest.mark.parametrize("threaded,count", [(False, 501), (True, 101)]) +async def test_oversized_page_or_pending_thread_queue_fails_before_commit( + database, source, threaded, count +): + oversized = { + "messages": [ + { + "ts": str(i), + "reply_count": int(threaded), + "text": "private fixture must not enter validation diagnostics", + } + for i in range(count) + ] + } + first, _, _ = await runner(database, source, [ROOT, oversized]) + with pytest.raises(ValueError, match="invalid page") as error: + await run(first) + assert "private fixture" not in str(error.value) + rows, cursor, scans = await saved(database) + assert {row.native_id for row in rows} == {"C1"} + child = next(scan for scan in scans if scan.record_type == "message") + assert child.phase == "collecting" and child.revision == 1 and child.continuation == {} + assert "canonical_checkpoint" not in cursor + + +async def test_interrupted_omission_confirmations_remove_nothing_then_retry(database, source): + from airweave.domains.entities.canonical.requests import RecordIdentity + from airweave.domains.entities.canonical.tests.helpers import capture, observation + + await capture( + database, + source[0], + source[1], + *( + observation(identity=RecordIdentity(record_type="channel", native_id=value)) + for value in ("C1", "C2") + ), + ) + first, _, _ = await runner( + database, + source, + [ + {"channels": []}, + SlackApiError("channel_not_found"), + ConnectionError("confirmation failed"), + ], + ) + with pytest.raises(ConnectionError, match="confirmation failed"): + await run(first) + rows, cursor, _ = await saved(database) + assert all(row.deleted_at is None for row in rows) + assert "canonical_checkpoint" not in cursor + second, connector, _ = await runner( + database, + source, + [{"channels": []}, SlackApiError("channel_not_found"), SlackApiError("channel_not_found")], + attempt=2, + ) + await run(second) + assert connector._get.await_count == 3 + rows, cursor, _ = await saved(database) + assert all(row.removal_reason == "scope_removed" for row in rows) + assert cursor["canonical_cycle"]["phase"] == "complete" + + +async def test_cleanup_budget_stop_preserves_reconciling_state_and_resumes(database, source): + from airweave.domains.entities.canonical.requests import RecordIdentity + from airweave.domains.entities.canonical.tests.helpers import capture, observation + + parent = RecordIdentity(record_type="channel", native_id="C1") + await capture( + database, + source[0], + source[1], + observation(identity=parent), + *( + observation( + identity=RecordIdentity(record_type="message", native_id=str(i), container_id="C1"), + parent=parent, + ) + for i in range(251) + ), + ) + first, _, _ = await runner(database, source, [ROOT, {"messages": []}]) + checks = 0 + + async def limit(): + nonlocal checks + checks += 1 + if checks == 5: + raise RuntimeError("synthetic cleanup budget reached") + + first._check_capture_limits = limit + with pytest.raises(RuntimeError, match="cleanup budget"): + await run(first) + rows, cursor, scans = await saved(database) + messages = [row for row in rows if row.entity_definition_short_name == "message"] + assert sum(row.deleted_at is not None for row in messages) == 250 + assert next(scan for scan in scans if scan.record_type == "message").phase == "reconciling" + assert "canonical_checkpoint" not in cursor + second, connector, _ = await runner(database, source, [ROOT], attempt=2) + await run(second) + assert connector._get.await_count == 1 + rows, cursor, _ = await saved(database) + assert all( + row.deleted_at is not None for row in rows if row.entity_definition_short_name == "message" + ) + assert cursor["canonical_cycle"]["phase"] == "complete" + + +@pytest.mark.parametrize("completed_child", [False, True]) +async def test_restored_root_restarts_withdrawn_child_scope_in_same_cycle( + database, source, completed_child +): + first_responses = [ + ROOT, + HISTORY, + REPLIES if completed_child else SlackApiError("not_in_channel"), + ] + first, _, _ = await runner(database, source, first_responses) + await run(first, checkpoint=False) + restore_attempt = 2 + if completed_child: + removed, _, _ = await runner( + database, source, [{"channels": []}, SlackApiError("channel_not_found")], attempt=2 + ) + await run(removed, checkpoint=False) + restore_attempt = 3 + before, cursor, scans = await saved(database) + assert all(row.deleted_at is not None for row in before) + old_sweep = next(scan for scan in scans if scan.record_type == "message").sweep_id + restored, connector, _ = await runner( + database, source, [ROOT, HISTORY, REPLIES], attempt=restore_attempt + ) + await run(restored) + operations = [call.args[0].split("/")[-1] for call in connector._get.call_args_list] + assert operations == ["conversations.list", "conversations.history", "conversations.replies"] + after, _, scans = await saved(database) + assert {row.native_id for row in after} == {"C1", "1", "1.1"} + assert all(row.deleted_at is None for row in after) + assert next(scan for scan in scans if scan.record_type == "message").sweep_id != old_sweep + + +async def test_root_revival_and_child_invalidation_roll_back_together(database, source): + from airweave.domains.entities.canonical.scan_store import CanonicalScanStore + + first, _, _ = await runner(database, source, [ROOT, HISTORY, REPLIES]) + await run(first, checkpoint=False) + removed, _, _ = await runner( + database, source, [{"channels": []}, SlackApiError("channel_not_found")], attempt=2 + ) + await run(removed, checkpoint=False) + before, _, scans = await saved(database) + root_before = next(row for row in before if row.native_id == "C1") + child_before = next(scan for scan in scans if scan.record_type == "message") + + class FailAfterPage(CanonicalScanStore): + async def page(self, db, request): + await super().page(db, request) + raise RuntimeError("synthetic failure after revival and invalidation") + + service = CanonicalCaptureService(source[0].store) + service.scans = FailAfterPage(service.store) + restoring, _, _ = await runner(database, source, [ROOT], attempt=3, service=service) + with pytest.raises(RuntimeError, match="after revival"): + await run(restoring) + after, _, scans = await saved(database) + root_after = next(row for row in after if row.native_id == "C1") + child_after = next(scan for scan in scans if scan.record_type == "message") + assert root_after.deleted_at == root_before.deleted_at + assert root_after.record_revision == root_before.record_revision + assert child_after.sweep_id == child_before.sweep_id + assert child_after.revision == child_before.revision and child_after.phase == "complete" + + +async def test_unavailable_parent_without_tombstone_also_invalidates_child_progress( + database, source +): + first, _, _ = await runner(database, source, [ROOT, HISTORY, SlackApiError("not_in_channel")]) + await run(first, checkpoint=False) + async with database() as db: + root = await db.scalar(select(Entity).where(Entity.native_id == "C1")) + root.deleted_at = None # Exercise the independent content-access gate representation. + await db.commit() + restored, connector, _ = await runner(database, source, [ROOT, HISTORY, REPLIES], attempt=2) + await run(restored) + assert connector._get.call_args_list[1].args[0].endswith("conversations.history") + rows, _, _ = await saved(database) + assert all(row.deleted_at is None and row.removal_reason is None for row in rows) + + +async def test_scope_withdrawal_keeps_uncertain_reason_without_claiming_access_revocation( + database, source +): + from airweave.domains.entities.canonical.page_source import ScopeAccessLost + + instance, _, pipeline = await runner(database, source, [ROOT, HISTORY]) + original_page = pipeline.page_source.capture_page + + async def unavailable(scope, continuation, *, files, parent=None): + if scope.record_type == "message" and continuation.value.get("pending_threads"): + raise ScopeAccessLost( + "Selected scope is no longer available", removal_reason="scope_removed" + ) + return await original_page(scope, continuation, files=files) + + pipeline.page_source.capture_page = unavailable + await run(instance) + rows, _, _ = await saved(database) + assert all(row.removal_reason == "scope_removed" for row in rows) + async with database() as db: + for row in rows: + value = await source[0].store.read( + db, source[1].organization_id, source[1].sync_id, row.id + ) + assert value.content_access == "unavailable" and value.payload == {} + + +@pytest.mark.parametrize("change", ["rename", "revive"]) +async def test_old_provider_route_failure_cannot_withdraw_changed_same_fence_parent( + database, source, change +): + from datetime import datetime, timezone + + from airweave.domains.entities.canonical.page_source import ScopeAccessLost + from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord + from airweave.domains.entities.canonical.scan_store import ScanConflict + + service, fence = source + instance, connector, pipeline = await runner(database, source, [ROOT]) + original_page = connector.capture_page + + async def change_during_network(scope, continuation, *, files, parent=None): + if parent is None: + return await original_page(scope, continuation, files=files, parent=parent) + update = CaptureRecord( + identity=parent.identity, + parent=parent.parent, + payload={**parent.payload, "name": "Current renamed channel"}, + observed_at=datetime.now(timezone.utc), + ) + records = (update,) + if change == "revive": + records = ( + update.model_copy(update={"kind": "delete", "removal_reason": "access_revoked"}), + update, + ) + async with database() as db: + await service.capture(db, CaptureBatch(fence=fence, records=records)) + raise ScopeAccessLost("Old endpoint routing reported access loss") + + connector.capture_page = change_during_network + with pytest.raises(ScanConflict, match="changed during provider access"): + await run(instance) + rows, cursor, scans = await saved(database) + current = next(row for row in rows if row.native_id == "C1") + assert current.deleted_at is None and current.removal_reason is None + assert current.source_payload["name"] == "Current renamed channel" + assert current.visibility_epoch == (2 if change == "revive" else 1) + assert "canonical_checkpoint" not in cursor + assert next(scan for scan in scans if scan.record_type == "message").phase == "collecting" + + +async def test_missing_queued_thread_restarts_inventory_before_absence(database, source): + instance, connector, _ = await runner( + database, + source, + [ + ROOT, + HISTORY, + SlackApiError("thread_not_found"), + {"messages": [{"ts": "2", "text": "Current message"}]}, + ], + ) + await run(instance) + assert [call.args[0].split("/")[-1] for call in connector._get.call_args_list] == [ + "conversations.list", + "conversations.history", + "conversations.replies", + "conversations.history", + ] + assert "cursor" not in connector._get.call_args_list[-1].args[1] + rows, cursor, scans = await saved(database) + old = next(row for row in rows if row.native_id == "1") + assert old.deleted_at is not None and old.removal_reason == "absent" + assert next(row for row in rows if row.native_id == "2").deleted_at is None + assert next(row for row in rows if row.native_id == "C1").deleted_at is None + assert cursor["canonical_cycle"]["phase"] == "complete" + assert all(scan.phase == "complete" for scan in scans) + + +async def test_native_slack_dates_reach_persisted_search_fields_and_date_filters(database, source): + from datetime import datetime, timezone + + from airweave.domains.entities.canonical.projection_mappers import map_record + from airweave.domains.entities.canonical.search_metadata import stamp_search_metadata + from airweave.domains.entities.canonical.store import source_record + from airweave.domains.search.owned import OwnedSearchService + from airweave.domains.search.owned_models import OwnedSearchRequest + from airweave.platform.destinations.vespa.transformer import EntityTransformer + from airweave.platform.entities._base import AirweaveSystemMetadata + + first, _, _ = await runner( + database, + source, + [ + ROOT, + { + "messages": [ + {"ts": "1767225600.000001", "text": "Before editing"}, + { + "ts": "1767225600.000002", + "text": "Edited", + "edited": {"ts": "1767312000.000003"}, + }, + ] + }, + ], + ) + await run(first) + rows, _, _ = await saved(database) + unchanged = next(row for row in rows if row.native_id == "1767225600.000001") + edited = next(row for row in rows if row.native_id == "1767225600.000002") + assert unchanged.source_created_at == datetime(2026, 1, 1, microsecond=1, tzinfo=timezone.utc) + assert unchanged.source_updated_at is None + assert edited.source_updated_at == datetime(2026, 1, 2, microsecond=3, tzinfo=timezone.utc) + + # Exercise the same canonical metadata stamper and Vespa serializer used by projection. + async with map_record(source_record(edited), "slack", MagicMock()) as entities: + entities = entities.entities + (entity,) = entities + entity.airweave_system_metadata = AirweaveSystemMetadata() + stamp_search_metadata(entity.airweave_system_metadata, source_record(edited)) + fields = EntityTransformer()._build_system_metadata(entity) + assert fields["source_created_us"] == 1767225600000002 + assert fields["source_updated_us"] == 1767312000000003 + assert fields["source_updated_known"] == 1 + assert fields["canonical_record_type"] == "message" + + # Current-SQL postvalidation enforces inclusive lower and exclusive upper bounds. + request = OwnedSearchRequest( + query="Edited", + sync_ids=(edited.sync_id,), + created_after=datetime(2026, 1, 1, microsecond=2, tzinfo=timezone.utc), + updated_after=datetime(2026, 1, 2, microsecond=3, tzinfo=timezone.utc), + ) + assert OwnedSearchService._matches(edited, request) + assert not OwnedSearchService._matches(unchanged, request) + assert not OwnedSearchService._matches( + edited, request.model_copy(update={"updated_before": edited.source_updated_at}) + ) + stamp_search_metadata(entity.airweave_system_metadata, source_record(unchanged)) + fields = EntityTransformer()._build_system_metadata(entity) + assert fields["source_updated_known"] == 0 + assert fields["source_updated_us"] is None diff --git a/backend/airweave/domains/entities/canonical/tests/test_strict_projection.py b/backend/airweave/domains/entities/canonical/tests/test_strict_projection.py new file mode 100644 index 0000000000..55eb2877b8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_strict_projection.py @@ -0,0 +1,70 @@ +"""Failure injection at conversion, chunking, embedding and remote-feed boundaries.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.domains.converters.fakes.registry import FakeConverterRegistry +from airweave.domains.sync_pipeline.exceptions import EntityProcessingError +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.platform.destinations.vespa.destination import VespaDestination +from airweave.platform.entities._base import AirweaveSystemMetadata +from airweave.platform.entities.slack import SlackMessageEntity + + +@pytest.mark.parametrize("stage", ["conversion", "chunking", "embedding"]) +async def test_strict_processor_rejects_dropped_required_content(stage): + processor = ChunkEmbedProcessor(FakeConverterRegistry(), MagicMock(), MagicMock()) + entity = SlackMessageEntity.from_api({"ts": "1.0", "text": "Required content"}, breadcrumbs=[]) + entity.entity_id = "part" + entity.airweave_system_metadata = AirweaveSystemMetadata() + chunk = entity.model_copy(deep=True) + chunk.entity_id = "part__chunk_0" + chunk.airweave_system_metadata.original_entity_id = "part" + entity.textual_representation = "Required content" + processor._text_builder.build_for_batch = AsyncMock( + return_value=[] if stage == "conversion" else [entity] + ) + processor._chunk_entities = AsyncMock(return_value=[] if stage == "chunking" else [chunk]) + processor._embed_entities = AsyncMock(return_value=[] if stage == "embedding" else [chunk]) + with pytest.raises(EntityProcessingError, match="dropped required"): + await processor.process( + [entity], + SimpleNamespace(logger=MagicMock()), + SimpleNamespace(entity_tracker=AsyncMock()), + strict=True, + ) + + +async def test_strict_destination_cannot_ack_dropped_transformations(): + destination = VespaDestination() + destination._client = AsyncMock() + destination._transformer = MagicMock() + destination._transformer.transform_batch.return_value = {} + with pytest.raises(RuntimeError, match="dropped required"): + await destination.bulk_insert([MagicMock()], strict=True) + destination._client.feed_documents.assert_not_called() + + +async def test_strict_delete_retries_partial_http_failure_and_accepts_absence(): + from airweave.platform.destinations.vespa.client import VespaClient + + client = VespaClient(MagicMock()) + transport = MagicMock() + transport.delete = AsyncMock( + side_effect=[ + SimpleNamespace(status_code=200), + SimpleNamespace(status_code=503), + ] + ) + documents = [("base_entity", "first"), ("base_entity", "second")] + with pytest.raises(RuntimeError, match="deletion incomplete"): + await client._delete_by_doc_ids(documents, transport, strict=True) + transport.delete = AsyncMock( + side_effect=[ + SimpleNamespace(status_code=404), + SimpleNamespace(status_code=200), + ] + ) + assert await client._delete_by_doc_ids(documents, transport, strict=True) == 2 diff --git a/backend/airweave/domains/entities/canonical/tests/test_stripe_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_stripe_recovery.py new file mode 100644 index 0000000000..90ad830f82 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_stripe_recovery.py @@ -0,0 +1,122 @@ +"""Stripe source composition and durable recovery; synthetic HTTP, real PostgreSQL.""" + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CompleteCycle +from airweave.domains.sources.exceptions import SourceEntityForbiddenError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.platform.configs.config import StripeCaptureConfig, StripeConfig +from airweave.platform.sources.stripe import StripeSource + + +async def connector(calls, *, fail=False): + async def get(url, **kwargs): + params = kwargs["params"] + calls.append((url, params)) + status = 200 + if url.endswith("/account"): + data = {"object": "account", "id": "acct_selected"} + elif url.endswith("/balance"): + data = {"object": "balance", "livemode": False, "available": []} + elif url.endswith("/customers"): + second = "starting_after" in params + if second and fail: + status, data = 403, {"error": {"message": "private provider detail"}} + else: + data = { + "object": "list", + "has_more": not second, + "data": [ + { + "id": "cus_second" if second else "cus_first", + "object": "customer", + "livemode": False, + "unknown": {"retained": [1, None]}, + } + ], + } + else: + data = {"object": "list", "data": [], "has_more": False} + return httpx.Response(status, json=data, request=httpx.Request("GET", url)) + + http = AsyncMock() + http.get.side_effect = get + + @asynccontextmanager + async def stream(method, url, **kwargs): + assert method == "GET" + yield await get(url, **kwargs) + + http.stream = MagicMock(side_effect=stream) + source = await StripeSource.create( + auth=ManagedAuthProvider( + api_key="fixture", + connected_account_id="fixture", + allowed_hosts=frozenset({"api.stripe.com"}), + ), + logger=MagicMock(), + http_client=http, + config=StripeConfig( + original_capture=StripeCaptureConfig( + expected_account_id="acct_selected", + livemode=False, + api_version="2025-06-30.basil", + ) + ), + ) + return source.capture_page_source + + +async def test_failed_customer_page_recovers_durable_cursor_and_preserves_original( + database, source +): + service, fence = source + first = await connector([], fail=True) + with pytest.raises(SourceEntityForbiddenError): + await CanonicalScanDriver( + service, database, fence, first, AsyncMock(), AsyncMock(), MagicMock() + ).run() + async with database() as db: + scan = await db.scalar(select(CaptureScan).where(CaptureScan.record_type == "customer")) + assert scan.phase == "collecting" + assert scan.continuation["starting_after"] == "cus_first" + before = await service.read_cycle(db, fence) + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + calls = [] + resumed = await connector(calls) + after = await CanonicalScanDriver( + service, database, newer, resumed, AsyncMock(), AsyncMock(), MagicMock() + ).run() + async with database() as db: + after = await service.complete_cycle(db, CompleteCycle(fence=newer, expected=after.version)) + records = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "customer") + ) + ).all() + ) + assert after.version.cycle_id == before.version.cycle_id and after.phase == "complete" + assert after.last_full_capture.discovery == "incomplete" + assert [ + params.get("starting_after") for url, params in calls if url.endswith("/customers") + ] == ["cus_first"] + assert len(records) == 2 + assert all(row.record_revision == 1 and row.deleted_at is None for row in records) + assert all(row.source_payload["unknown"] == {"retained": [1, None]} for row in records) diff --git a/backend/airweave/domains/entities/canonical/tests/test_wispr_diagnostics.py b/backend/airweave/domains/entities/canonical/tests/test_wispr_diagnostics.py new file mode 100644 index 0000000000..26cb650819 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_wispr_diagnostics.py @@ -0,0 +1,92 @@ +"""Safe metadata around actual Wispr validation; no network.""" + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + + +async def test_safe_validation_failure_metadata_without_payload_leak(): + script = r""" +import asyncio, json, os, sys +from types import SimpleNamespace +from uuid import uuid4 +sys.path.insert(0, "tests/live") +import conftest +import httpx +import provider_sample +from wispr_diagnostics import WisprDiagnostics +from airweave.domains.sources.exceptions import SourceServerError + +async def main(): + original_client = httpx.AsyncClient + for error, kind, rate in [("private-detail rate limit", "string", True), + ({"status_code": 429, "private": "secret"}, "object", True), + ("private-detail failure", "string", False)]: + diagnostics = WisprDiagnostics() + calls = [] + async def handle(request): + calls.append(diagnostics.operation) + if diagnostics.operation == "metadata": + value = {"id": "fixture", "status": "ACTIVE", "user_id": "fixture", + "toolkit": {"slug": "wispr_flow_mcp"}} + elif diagnostics.operation == "session": + value = {"session_id": "fixture"} + else: + value = {"data": {}, "error": error} + return httpx.Response(200, json=value) + class MockClient(original_client): + def __init__(self, **kwargs): + super().__init__(transport=httpx.MockTransport(handle), **kwargs) + provider_sample.httpx.AsyncClient = MockClient + async def request(req): diagnostics.request(req) + os.environ["LIVE_WISPR_USER_ID"] = "fixture" + try: + async with provider_sample.wispr_source("fixture", "fixture", + SimpleNamespace(organization_id=uuid4()), request_hook=request, + response_hook=diagnostics.response, envelope_hook=diagnostics.envelope): + raise AssertionError("Validation error swallowed") + except (ValueError, SourceServerError) as exc: + if kind == "string" and rate: + assert isinstance(exc, SourceServerError) + assert "rate-limit signal" in str(exc) + assert exc.status_code is None + else: + assert str(exc) == "Wispr tool execution failed; capture is incomplete" + assert "private" not in str(exc) and "secret" not in str(exc) + assert calls == ["metadata", "session", "search"] + assert diagnostics.operation == "search" and diagnostics.last_http_status == 200 + assert diagnostics.tool_error_present and diagnostics.tool_error_kind == kind + assert diagnostics.rate_signal_detected == rate + assert "private" not in diagnostics.model_dump_json() + assert "secret" not in diagnostics.model_dump_json() + req = httpx.Request("POST", "https://backend.composio.dev/api/v3.1/tool_router/session/x/execute", + json={"tool_slug": "WISPR_FLOW_MCP_GET_MEETING", + "arguments": {"id": "private"}}) + diagnostics.request(req) + await diagnostics.response(httpx.Response(429)) + assert diagnostics.operation == "get" and diagnostics.rate_signal_detected + assert diagnostics.tool_error_present is None + print("verified") +asyncio.run(main()) +""" + code, output, error = await subprocess_script(script, {}) + assert code == 0, error + assert output.splitlines()[-1] == "verified" + + +async def test_reduced_wispr_budget_validation_before_network(): + script = r""" +import sys +sys.path.insert(0, "tests/live") +import conftest +from provider_lifecycle import wispr_request_limit +for value in ("0", "21", "-1", "no", "1.5"): + try: wispr_request_limit(value) + except ValueError: pass + else: raise AssertionError("Invalid budget accepted") +assert wispr_request_limit("17") == 17 +assert wispr_request_limit("20") == 20 +assert wispr_request_limit("1") == 1 +print("verified") +""" + code, output, error = await subprocess_script(script, {}) + assert code == 0, error + assert output.splitlines()[-1] == "verified" diff --git a/backend/airweave/domains/entities/canonical/tests/test_wispr_recovery.py b/backend/airweave/domains/entities/canonical/tests/test_wispr_recovery.py new file mode 100644 index 0000000000..78a3b75eea --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_wispr_recovery.py @@ -0,0 +1,337 @@ +"""Wispr resumes completed bodies through the real canonical forest, without absence claims.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord, RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalStoreError +from airweave.domains.sources.exceptions import SourceServerError +from airweave.domains.sources.token_providers.protocol import ManagedToolAuthProvider +from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.platform.sources.wispr import WisprSource + + +async def connector(rows, body_calls, *, fail_at=None, failure=None): + result = await WisprSource.create( + auth=ManagedToolAuthProvider( + api_key="fixture", connected_account_id="fixture", user_id="fixture" + ), + logger=MagicMock(), + http_client=MagicMock(), + ) + + async def execute(slug, arguments): + if slug == "WISPR_FLOW_MCP_SEARCH_SCRATCHPAD_NOTES": + return {"notes": [], "has_more": False} + if slug == "WISPR_FLOW_MCP_SEARCH_MEETINGS": + return {"meetings": rows, "has_more": False} + native = arguments["meeting_id"] + body_calls.append(native) + if len(body_calls) == fail_at: + raise failure or ValueError("Synthetic opaque body failure") + return { + "id": native, + "content": "notes", + "transcript": "transcript", + "modified_at": "2026-09-30T00:00:00Z", + "start": "2026-09-20T00:00:00Z", + } + + result._execute = execute + return result + + +def driver(service, database, fence, source): + return CanonicalScanDriver( + service, database, fence, source, AsyncMock(), AsyncMock(), MagicMock() + ) + + +@pytest.mark.parametrize( + "failure", + [ + ValueError("Synthetic opaque body failure"), + SourceServerError("Synthetic rate signal; retry delay unknown", source_short_name="wispr"), + ], +) +async def test_failed_body_resumes_without_repeating_completed_siblings(database, source, failure): + service, fence = source + rows = [ + {"id": f"m{i}", "title": "Earlier listing", "start": "2026-09-20T00:00:00Z"} + for i in range(10) + ] + calls = [] + first = await connector(rows, calls, fail_at=10, failure=failure) + with pytest.raises(type(failure), match="Synthetic"): + await driver(service, database, fence, first).run() + assert len(calls) == 10 + failed = calls[-1] + async with database() as db: + scans = list( + ( + await db.scalars(select(CaptureScan).where(CaptureScan.record_type == "meeting")) + ).all() + ) + assert sum(scan.phase == "complete" for scan in scans) == 9 + assert sum(scan.phase == "collecting" for scan in scans) == 1 + before = await service.read_cycle(db, fence) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + # Listing changes must not masquerade as body freshness. + changed = [ + {**row, "title": "Updated listing", "modified_at": "2026-10-01T00:00:00Z"} + for row in rows + if row["id"] != calls[0] + ] + second = await connector(changed, calls) + after = await driver(service, database, newer, second).run() + assert before.version.cycle_id == after.version.cycle_id + assert len(calls) == 11 and calls[-1] == failed + async with database() as db: + bodies = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "meeting") + ) + ).all() + ) + assert len(bodies) == 10 + assert all(row.deleted_at is None for row in bodies) + assert all(row.source_created_at is None for row in bodies) + assert all( + row.source_updated_at == datetime(2026, 9, 30, tzinfo=timezone.utc) for row in bodies + ) + # Inventory refresh does not rewrite the body captured on the prior attempt. + assert ( + sum(row.source_payload["listing"]["title"] == "Earlier listing" for row in bodies) == 9 + ) + + +async def test_old_parentless_body_requires_explicit_migration(database, source): + service, fence = source + async with database() as db: + await service.capture( + db, + CaptureBatch( + fence=fence, + records=( + CaptureRecord( + identity=RecordIdentity(record_type="meeting", native_id="old"), + payload={"prior": True}, + completeness="partial", + observed_at=datetime.now(timezone.utc), + ), + ), + ), + ) + current = await connector([{"id": "old", "start": "2026-09-20T00:00:00Z"}], []) + with pytest.raises(CanonicalStoreError, match="different parent"): + await driver(service, database, fence, current).run() + async with database() as db: + body = await db.scalar( + select(Entity).where(Entity.entity_definition_short_name == "meeting") + ) + assert body.source_payload == {"prior": True} + assert body.parent_record_type is None + + +async def test_scratchpad_failure_resumes_failed_body_without_replaying_completed_notes( + database, source +): + service, fence = source + listings = [ + {"id": "same-id", "title": "First", "modified_at": "2026-09-30T00:00:00Z"}, + {"id": "n2", "title": "Second", "modified_at": "2026-09-30T00:00:00Z"}, + ] + calls = [] + failing = True + completed: set[str] = set() + failed_native: str | None = None + + async def execute(slug, arguments): + nonlocal failed_native + if slug == "WISPR_FLOW_MCP_SEARCH_MEETINGS": + # Native IDs may collide between resource kinds without aliasing storage. + return {"meetings": [{"id": "same-id"}], "has_more": False} + if slug == "WISPR_FLOW_MCP_SEARCH_SCRATCHPAD_NOTES": + return {"notes": listings, "has_more": False} + if slug == "WISPR_FLOW_MCP_GET_MEETING": + return {"id": "same-id", "content": "meeting", "transcript": "verbatim"} + assert slug == "WISPR_FLOW_MCP_GET_SCRATCHPAD_NOTE" + native = arguments["note_id"] + offset = arguments["view_content"]["start_char"] + calls.append((native, offset)) + if failing and len(completed) == 1 and offset == 3: + failed_native = native + raise SourceServerError("Synthetic Wispr rate signal", source_short_name="wispr") + if offset == 3: + completed.add(native) + return { + "id": native, + "modified_at": "2026-09-30T00:00:00Z", + "content": ( + "abc\n(...truncated, 3 chars remaining; continue with view_content.start_char=3...)" + ) + if offset == 0 + else "def", + } + + first = await connector([], []) + first._execute = execute + with pytest.raises(SourceServerError): + await driver(service, database, fence, first).run() + async with database() as db: + notes = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "scratchpad_note") + ) + ).all() + ) + assert len(notes) == 1 and notes[0].native_id in completed + assert notes[0].record_revision == 1 + before = await service.read_cycle(db, fence) + async with database() as db: + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + assert failed_native is not None and failed_native not in completed + failing = False + calls.clear() + second = await connector([], []) + second._execute = execute + after = await driver(service, database, newer, second).run() + assert before.version.cycle_id == after.version.cycle_id + # Durable scope order follows stored UUIDs, not the provider listing order. + assert calls == [ + (failed_native, 0), + (failed_native, 3), + ] # Bodies commit atomically, ranges do not checkpoint separately. + async with database() as db: + rows = list( + ( + await db.scalars( + select(Entity).where( + Entity.entity_definition_short_name.in_(["meeting", "scratchpad_note"]) + ) + ) + ).all() + ) + assert len(rows) == 3 and all( + row.deleted_at is None and row.record_revision == 1 for row in rows + ) + notes = [row for row in rows if row.entity_definition_short_name == "scratchpad_note"] + assert all(len(row.source_payload["responses"]) == 2 for row in notes) + assert all(row.completeness == "partial" for row in notes) + + +async def test_active_meeting_only_cycle_requires_explicit_restart_for_scratchpad(database, source): + import hashlib + import json + + from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration + from airweave.domains.entities.canonical.cycle_store import CycleConflict + + service, fence = source + old_fingerprint = hashlib.sha256( + json.dumps( + { + "version": 2, + "account": "fixture", + "inventory": "meeting_listing", + "body": "meeting", + "policy": "discovery_only", + "listing_limit": 200, + }, + sort_keys=True, + ).encode() + ).hexdigest() + old = CycleConfiguration.from_source( + fingerprint=old_fingerprint, + record_types=("meeting_listing", "meeting"), + container_parents={"meeting": "meeting_listing"}, + completion_policies={"meeting_listing": "discovery_only"}, + ) + async with database() as db: + before = await service.begin_cycle(db, BeginCycle(fence=fence, configuration=old)) + current = await connector([], []) + with pytest.raises(CycleConflict, match="configuration changed"): + await driver(service, database, fence, current).run() + async with database() as db: + after = await service.read_cycle(db, fence) + assert after == before + + +async def test_body_byte_overflow_preserves_committed_siblings_and_resume( + database, source, monkeypatch +): + from airweave.domains.sources.exceptions import SourceError + from airweave.platform.sources import wispr + + service, fence = source + rows = [{"id": f"bounded{i}", "start": "2026-09-20T00:00:00Z"} for i in range(2)] + calls = [] + first = await connector(rows, calls) + execute = first._execute + + async def oversized_second(slug, arguments): + result = await execute(slug, arguments) + if "meeting_id" in arguments and len(calls) == 2: + result["native_metadata"] = "x" * 2048 + return result + + first._execute = oversized_second + monkeypatch.setattr(wispr, "MAX_BODY_BYTES", 1024) + with pytest.raises(SourceError, match="body exceeds byte limit"): + await driver(service, database, fence, first).run() + async with database() as db: + states = list( + ( + await db.scalars(select(CaptureScan).where(CaptureScan.record_type == "meeting")) + ).all() + ) + assert sorted(state.phase for state in states) == ["collecting", "complete"] + bodies = list( + ( + await db.scalars( + select(Entity).where(Entity.entity_definition_short_name == "meeting") + ) + ).all() + ) + assert len(bodies) == 1 and bodies[0].native_id == calls[0] + newer = await service.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + second = await connector(rows, calls) + completed = await driver(service, database, newer, second).run() + from airweave.domains.entities.canonical.cycle_models import CompleteCycle + + async with database() as db: + completed = await service.complete_cycle( + db, CompleteCycle(fence=newer, expected=completed.version) + ) + assert completed.phase == "complete" + assert len(calls) == 3 and calls[1] == calls[2] diff --git a/backend/airweave/domains/entities/canonical/tests/test_wispr_resume_probe.py b/backend/airweave/domains/entities/canonical/tests/test_wispr_resume_probe.py new file mode 100644 index 0000000000..1d8b9aa6d2 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_wispr_resume_probe.py @@ -0,0 +1,224 @@ +"""Actual process exit/restart around committed Wispr pages; synthetic provider only.""" + +import json + +from sqlalchemy import text + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + +SCRIPT = r""" +import asyncio, os, sys +from pathlib import Path +from uuid import uuid4 +sys.path.insert(0, "tests/live") +import conftest +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from airweave.domains.entities.canonical.requests import WriterFence +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.test_wispr_recovery import connector, driver +from wispr_resume import WisprResumeProbe +from provider_lifecycle import BoundedPageSource +from airweave.domains.entities.canonical.tests.test_capture_pipeline import components, orchestrator +from airweave.domains.entities.canonical.tests.test_slack_recovery import run +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from unittest.mock import MagicMock + +async def main(): + engine = create_async_engine(os.environ["CANONICAL_TEST_DATABASE_URL"], + connect_args={"server_settings": {"search_path": os.environ["TEST_SCHEMA"]}}) + sessions = async_sessionmaker(engine, expire_on_commit=False) + fence = WriterFence.model_validate_json(os.environ["TEST_FENCE"]) + service = CanonicalCaptureService(CanonicalRecordStore()) + mode = os.environ["TEST_MODE"] + if mode == "resume": + async with sessions() as db: + fence = await service.activate_writer(db, fence.organization_id, fence.sync_id, + fence.job_id, attempt_id=uuid4(), attempt_number=2) + calls = [] + source = await connector([{"id": str(i)} for i in range(6)], calls, + fail_at=2 if mode == "fail" else None) + if os.environ.get("TEST_MIXED") == "1": + async def mixed(slug, arguments): + rows=[{"id":"private-same-id-"+str(i)} for i in range(3)] + if slug == "WISPR_FLOW_MCP_SEARCH_MEETINGS": + return {"meetings":rows,"has_more":False} + if slug == "WISPR_FLOW_MCP_SEARCH_SCRATCHPAD_NOTES": + return {"notes":rows,"has_more":False} + if slug == "WISPR_FLOW_MCP_GET_MEETING": + return {"id":arguments["meeting_id"],"content":"private meeting text", + "transcript":"private transcript text"} + assert slug == "WISPR_FLOW_MCP_GET_SCRATCHPAD_NOTE" + return {"id":arguments["note_id"],"content":"private scratchpad text"} + source._execute = mixed + counters = {"provider_requests": 0, "records_observed": 0} + execute = source._execute + async def counted(*args): + counters["provider_requests"] += 1 + return await execute(*args) + source._execute = counted + probe = WisprResumeProbe(sessions, fence.organization_id, fence.sync_id, fence.job_id, + fence.attempt_id, Path(os.environ["TEST_TARGET"]), + "interrupt" if mode == "fail" else mode, counters) + ctx, _, runtime, bus = components(sessions, (service, fence)) + wrapped = BoundedPageSource(source, counters, 600, probe) + if mode == "wrong_topology": + parents = {} + else: + parents = source.canonical_container_parents + pipeline = CanonicalCapturePipeline(service, sessions, bus, source.canonical_record_types, + CaptureAttempt(id=fence.attempt_id, number=fence.attempt_number), parents, + page_source=wrapped, files=MagicMock()) + runtime.source, runtime.canonical_capture = source, pipeline + instance = orchestrator(ctx, pipeline, runtime, None, bus) + instance.stream = None + await run(instance) +asyncio.run(main()) +""" + + +async def test_wispr_process_restart_skips_completed_bodies(database, source, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + env = { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + } + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "interrupt"}) + assert code == 75, error + first = json.loads(output.splitlines()[-1]) + assert first["completed_bodies"] == 3 and first["provider_requests"] == 5 + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "resume"}) + assert code == 76, error + second = json.loads(output.splitlines()[-1]) + assert second["wispr_recovery_verified"] and second["provider_requests"] == 3 + assert not second["full_scope_completed"] + assert (tmp_path / "target.json").stat().st_mode & 0o077 == 0 + + +async def test_wispr_failure_does_not_claim_interruption(database, source, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + code, output, error = await subprocess_script( + SCRIPT, + { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + "TEST_MODE": "fail", + }, + ) + assert code not in (75, 76) + assert "Synthetic opaque body failure" in error + assert not (tmp_path / "target.json").exists() + + +async def test_wispr_trial_budget_is_aggregate_and_failure_stops(tmp_path): + script = r""" +import asyncio, os, sys +sys.path.insert(0, "tests/live") +import conftest +import provider_lifecycle as lifecycle +async def main(): + for fail in (False, True): + snapshots = [] + async def execute(path, timeout): + import json + value = json.loads(path.read_text()) + snapshots.append(value) + if fail: + return 1, {"failed": True, "provider_requests": 2, "records_observed": 0} + second = len(snapshots) == 2 + return (76 if second else 75), {"failed": False, + "intentional_interruption": not second, "wispr_recovery_verified": second, + "provider_requests": 7, "records_observed": 203} + lifecycle.execute_trial = execute + results = await lifecycle.run_trials({"provider": "wispr", "wispr_resume": True, + "root": os.environ["TEST_ROOT"], "request_limit": 20, + "record_limit": 600, "timeout": 180}) + if fail: + assert len(snapshots) == 1 + else: + assert len(snapshots) == 2 + assert [v["request_limit"] for v in snapshots] == [20, 13] + assert [v["record_limit"] for v in snapshots] == [600, 397] + assert snapshots[0]["job_id"] == snapshots[1]["job_id"] + assert [v["attempt_number"] for v in snapshots] == [1, 2] + assert 180 >= snapshots[0]["timeout"] >= snapshots[1]["timeout"] > 0 + print("verified") +asyncio.run(main()) +""" + code, output, error = await subprocess_script(script, {"TEST_ROOT": str(tmp_path)}) + assert code == 0, error + assert output.splitlines()[-1] == "verified" + + +async def test_wispr_pipeline_rejects_actual_topology_mismatch(database, source, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + code, output, error = await subprocess_script( + SCRIPT, + { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(tmp_path / "target.json"), + "TEST_MODE": "wrong_topology", + }, + ) + assert code not in (0, 75, 76) + assert "Page source cycle must match its declared container topology" in error + + +async def test_wispr_legacy_target_is_rejected_without_private_output(tmp_path): + script = r""" +import sys, json, os +from pathlib import Path +from uuid import uuid4 +sys.path.insert(0,"tests/live") +import conftest +from wispr_resume import WisprResumeProbe +path=Path(os.environ["TARGET"]) +path.write_text(json.dumps({"cycle_id":str(uuid4()),"bodies":[]})) +try: + WisprResumeProbe(None,None,None,None,None,path,"resume",{}) +except ValueError as exc: + assert str(exc)=="Incompatible Wispr resume target; start a new private v3 trial" +else: + raise AssertionError("Legacy target accepted") +print("verified") +""" + code, output, error = await subprocess_script( + script, {"TARGET": str(tmp_path / "old-target.json")} + ) + assert code == 0, error + assert output.strip() == "verified" + + +async def test_mixed_kind_native_id_collision_survives_real_process_resume( + database, source, tmp_path +): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + target = tmp_path / "mixed-target.json" + env = { + "TEST_SCHEMA": schema, + "TEST_FENCE": source[1].model_dump_json(), + "TEST_TARGET": str(target), + "TEST_MIXED": "1", + } + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "interrupt"}) + assert code == 75, error + saved = json.loads(target.read_text()) + assert saved["schema_version"] == 3 and len(saved["configuration_digest"]) == 64 + assert len(saved["bodies"]) == 3 + assert all("record_type" in body for body in saved["bodies"]) + assert "private" not in output and "private" not in target.read_text() + code, output, error = await subprocess_script(SCRIPT, {**env, "TEST_MODE": "resume"}) + assert code == 76, error + evidence = json.loads(output.splitlines()[-1]) + assert evidence["wispr_recovery_verified"] and evidence["completed_bodies"] == 4 + assert all(count > 0 for count in evidence["completed_body_kinds"].values()) + assert "private" not in output + assert target.stat().st_mode & 0o077 == 0 diff --git a/backend/airweave/domains/entities/canonical/tests/test_workspace_capture.py b/backend/airweave/domains/entities/canonical/tests/test_workspace_capture.py new file mode 100644 index 0000000000..906c8c85f8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_workspace_capture.py @@ -0,0 +1,162 @@ +"""Actual Drive pipeline publishes Docs references atomically; HTTP is synthetic.""" + +import hashlib +from contextlib import aclosing +from unittest.mock import AsyncMock + +import httpx +import pytest +from sqlalchemy import select + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.entities.canonical.query import CanonicalQueryService, RecordNotFound +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.helpers import capture +from airweave.domains.entities.canonical.tests.test_drive_recovery import run, setup +from airweave.domains.storage.file_service import FileService +from airweave.models.entity import Entity +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_pages import DrivePages + + +class NativeDocs: + def __init__(self): + self.fail = True + + async def handle(self, request): + path = request.url.path + if path.endswith("/about"): + assert request.url.params["fields"] == "user(permissionId,me)" + return httpx.Response( + 200, json={"user": {"permissionId": "principal-a", "me": True}} + ) + if request.url.host == "docs.googleapis.com": + if self.fail: + raise ConnectionError("interrupted native acquisition") + return httpx.Response( + 200, + json={ + "documentId": "doc", + "tabs": [ + { + "tabProperties": {"tabId": "one"}, + "documentTab": {"body": {"content": []}}, + } + ], + }, + ) + if path.endswith("/export"): + return httpx.Response(200, content=b"retained-export") + if path.endswith("/startPageToken"): + return httpx.Response(200, json={"startPageToken": "start"}) + if path.endswith("/files"): + return httpx.Response(200, json={"files": [{"id": "doc"}]}) + if path.endswith("/files/doc"): + return httpx.Response( + 200, + json={ + "id": "doc", + "version": "7", + "mimeType": "application/vnd.google-apps.document", + }, + ) + if path.endswith("/changes"): + return httpx.Response(200, json={"changes": [], "newStartPageToken": "end"}) + raise AssertionError("Unexpected synthetic provider endpoint") + + +async def test_recapture_original_uuid_atomically_adds_manifest_and_withdraws_all_parts( + database, source, tmp_path, monkeypatch +): + service, fence = source + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + files = FileService( + fence.job_id, FilesystemBackend(tmp_path / "storage"), sync_id=fence.sync_id + ) + old_blob = await files.store_canonical_blob( + b"retained-export", + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + old = file_record( + {"id": "doc", "version": "7", "mimeType": "application/vnd.google-apps.document"} + ).model_copy( + update={"blobs": (old_blob,), "content_hash": old_blob.sha256, "completeness": "complete"} + ) + previous = (await capture(database, service, fence, old)).changes[0].record + native = NativeDocs() + pipeline, ctx, runtime, client = await setup(database, source, native) + pipeline.files = files + async with aclosing(client): + with pytest.raises(ConnectionError, match="interrupted"): + await run(pipeline, ctx, runtime) + async with database() as db: + unchanged = await db.get(Entity, previous.id) + assert unchanged.record_revision == previous.revision + assert len(unchanged.blob_references) == 1 + cycle = await service.read_cycle(db, pipeline._writer()) + assert cycle.promoted_checkpoint is None + native.fail = False + pipeline, ctx, runtime, client = await setup(database, source, native, attempt=2) + pipeline.files = files + async with aclosing(client): + await run(pipeline, ctx, runtime) + async with database() as db: + completed = await service.read_cycle(db, pipeline._writer()) + # A pre-native completed Drive boundary cannot authorize a quiet delta that + # would leave export-only Docs permanently without their native representation. + previous_cycle = completed.model_copy( + update={ + "configuration": completed.configuration.model_copy( + update={"fingerprint": hashlib.sha256(b"drive-all-accessible-v1").hexdigest()} + ) + } + ) + boundary = AsyncMock(return_value={"startPageToken": "new-native-baseline"}) + plan = await DrivePages(boundary).prepare(previous_cycle, completed.configuration) + assert plan.mode == "full" + assert plan.starting_checkpoint.value == {"page_token": "new-native-baseline"} + boundary.assert_awaited_once() + query = CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-key") + async with database() as db: + current = await query.read(db, fence.organization_id, fence.sync_id, previous.id) + assert current.id == previous.id and current.revision == previous.revision + 1 + assert len(current.blobs) == 3 + assert sum(blob.role == "representation_manifest" for blob in current.blobs) == 1 + for blob in current.blobs: + assert await query.blob( + db, + fence.organization_id, + fence.sync_id, + current.id, + current.revision, + blob.sha256, + files.storage, + ) + await capture( + database, + service, + pipeline._writer(), + old.model_copy( + update={ + "kind": "delete", + "removal_reason": "scope_removed", + } + ), + ) + async with database() as db: + for blob in current.blobs: + with pytest.raises(RecordNotFound): + await query.blob( + db, + fence.organization_id, + fence.sync_id, + current.id, + current.revision, + blob.sha256, + files.storage, + ) + assert len((await db.scalars(select(Entity))).all()) == 1 diff --git a/backend/airweave/domains/entities/canonical/tests/test_workspace_document_probe.py b/backend/airweave/domains/entities/canonical/tests/test_workspace_document_probe.py new file mode 100644 index 0000000000..ea9442c099 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/test_workspace_document_probe.py @@ -0,0 +1,113 @@ +"""Exercise the opt-in Docs sample with fake provider HTTP and real SQL/HTTP/process reads.""" + +import json +import os + +import pytest +from sqlalchemy import text + +from airweave.domains.entities.canonical.tests.test_resume_probe import subprocess_script + +SCRIPT = r""" +import asyncio,json,os,sys +from contextlib import asynccontextmanager +from pathlib import Path +from unittest.mock import MagicMock +sys.path.insert(0, 'tests/live') +import canonical_capture as harness +import workspace_document +import httpx +from sqlalchemy.ext.asyncio import create_async_engine,async_sessionmaker +from provider_sample import verify_rest_identity +from airweave.platform.sources.google_drive import GoogleDriveSource +from airweave.platform.configs.config import GoogleDriveConfig +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.paths import StoragePaths + +document = {'documentId':'doc','title':'Fixture','tabs':[ + {'tabProperties':{'tabId':'root'},'documentTab':{'body':{'content':[]}}, + 'childTabs':[{'tabProperties':{'tabId':'child'},'documentTab':{'body':{'content':[]}}}]}]} +metadata = {'id':'doc','name':'Fixture','mimeType':'application/vnd.google-apps.document', + 'version':'7'} +seen=[] +def response(request): + assert request.method=='GET' + seen.append(request.url.path) + if request.url.path.endswith('/about'): + return httpx.Response(200,json={'user':{'permissionId':'principal-a','me':True}}) + if request.url.path=='/drive/v3/files': + return httpx.Response(200,json={'files':[metadata]}) + if request.url.path.endswith('/export'): + return httpx.Response(200,content=b'synthetic-docx') + if request.url.host=='docs.googleapis.com': + return httpx.Response(200,json=document) + assert request.url.path=='/drive/v3/files/doc' + return httpx.Response(200,json={'version':'7'}) + +@asynccontextmanager +async def source(name,account,email,key,fence,**options): + async with httpx.AsyncClient(transport=httpx.MockTransport(response), + event_hooks={'request':[options['request_hook']]}) as client: + connector=await GoogleDriveSource.create(auth=StaticTokenProvider('synthetic'), + logger=MagicMock(),http_client=client,config=GoogleDriveConfig(expected_permission_id="principal-a")) + yield connector,'provider_email' + +async def main(): + workspace_document.rest_source=source + os.environ['LIVE_WORKSPACE_DOCUMENT']='1' + root=Path(os.environ['TEST_ROOT']);root.chmod(0o700) + StoragePaths.TEMP_PROCESSING=str(root/'temp') + engine=create_async_engine(os.environ['CANONICAL_TEST_DATABASE_URL'], + connect_args={'server_settings':{'search_path':os.environ['TEST_SCHEMA']}}) + try: + result=await harness.verify('google_drive','synthetic','synthetic@example.test', + 'synthetic',async_sessionmaker(engine,expire_on_commit=False),engine,root) + assert len(seen)==5 + print(json.dumps(result)) + finally: + await engine.dispose() +asyncio.run(main()) +""" + + +async def test_workspace_sample_survives_sql_reopen_and_real_http_reader(database, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + code, output, error = await subprocess_script( + SCRIPT, {"TEST_ROOT": str(tmp_path), "TEST_SCHEMA": schema} + ) + assert code == 0, output + error + result = json.loads(output.splitlines()[-1]) + assert result["records"] == result["journal_changes"] == 1 + assert result["blobs"] == 3 and result["blob_sha_verified"] + assert result["replay_new_changes"] == 0 and result["fresh_connections_readback"] + assert not result["full_scope_completed"] and not result["checkpoint_saved"] + assert result["almanac_reader"] is None + assert result["document_reader"]["verified"] + assert result["document_reader"]["stale_revision_denied"] + assert result["document_reader"]["wrong_source_denied"] + + +@pytest.mark.skipif( + not os.environ.get("ALMANAC_PROBE_ROOT"), reason="Explicit companion checkout required" +) +async def test_workspace_sample_through_almanac_cli(database, tmp_path): + async with database() as db: + schema = await db.scalar(text("select current_schema()")) + code, output, error = await subprocess_script( + SCRIPT, + { + "TEST_ROOT": str(tmp_path), + "TEST_SCHEMA": schema, + "LIVE_ALMANAC_ROOT": os.environ["ALMANAC_PROBE_ROOT"], + "LIVE_ALMANAC_PYTHON": os.environ["ALMANAC_PROBE_PYTHON"], + }, + ) + assert code == 0, output + error + result = json.loads(output.splitlines()[-1]) + assert result["document_reader"]["verified"] + assert ( + result["document_reader"]["scope"] + == "actual_cli_and_almanac_fixture_route_to_real_store_http" + ) + assert result["document_reader"]["revoked_binding_denied"] diff --git a/backend/airweave/domains/entities/canonical/tests/vespa_helpers.py b/backend/airweave/domains/entities/canonical/tests/vespa_helpers.py new file mode 100644 index 0000000000..ad3b5a1fa2 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/tests/vespa_helpers.py @@ -0,0 +1,47 @@ +"""Disposable real Vespa schema deployment for synthetic integration tests.""" + +import asyncio +import io +import time +import zipfile +from pathlib import Path + +import httpx + + +async def wait_healthy(client, url, seconds=180): + """Bound readiness independently of the enclosing CI job timeout.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + try: + response = await client.get(url) + if ( + response.status_code == 200 + and response.json().get("status", {}).get("code") == "up" + ): + return + except (httpx.TransportError, ValueError): + pass + await asyncio.sleep(2) + raise AssertionError(f"Disposable Vespa did not become ready: {url}") + + +async def deploy_schema(http): + """Deploy only to the explicitly disposable engine used by these tests.""" + package = Path(__file__).resolve().parents[6] / "vespa/app" + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as output: + for path in sorted(package.rglob("*")): + if path.is_file(): + content = path.read_text().replace("{{EMBEDDING_DIM}}", "384") + output.writestr( + str(path.relative_to(package)), content.replace("{{VERSION}}", "test") + ) + await wait_healthy(http, "http://localhost:19071/state/v1/health") + deployed = await http.post( + "http://localhost:19071/application/v2/tenant/default/prepareandactivate", + content=archive.getvalue(), + headers={"Content-Type": "application/zip"}, + ) + assert deployed.status_code == 200, deployed.text + await wait_healthy(http, "http://localhost:8081/state/v1/health") diff --git a/backend/airweave/domains/entities/canonical/text_artifacts.py b/backend/airweave/domains/entities/canonical/text_artifacts.py new file mode 100644 index 0000000000..0d4055f0e8 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/text_artifacts.py @@ -0,0 +1,53 @@ +"""Build immutable text bytes once, then publish alongside the index generation.""" + +import hashlib +from uuid import UUID + +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.text_models import TextArtifact, representation_id +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES +from airweave.domains.sync_pipeline.pipeline.text_models import BuiltText + + +def prepare_text( + built: tuple[BuiltText, ...], + generation: UUID, +) -> tuple[tuple[TextArtifact, bytes], ...]: + """Keep complete converter output; oversized text fails rather than truncating.""" + artifacts = [] + for item in built: + locator = ProjectionLocator.parse(item.entity_id) + if locator is None or locator.generation != generation: + raise ValueError("Built text lost its publication identity") + content = item.text.encode("utf-8") + if len(content) > MAX_FILE_SIZE_BYTES: + raise ValueError("Derived text exceeds retained representation size limit") + artifact = TextArtifact( + id=representation_id(generation, locator.part_index), + part_index=locator.part_index, + sha256=hashlib.sha256(content).hexdigest(), + size_bytes=len(content), + characters=len(item.text), + content_start=item.content_start, + kind=item.kind, + ) + artifacts.append((artifact, content)) + return tuple(artifacts) + + +def text_manifest( + artifacts: tuple[TextArtifact, ...] | None, + indexed_parts: set[int], + sync_id: UUID, + generation: UUID, +) -> list[dict] | None: + """Validate exact part membership and generation-owned keys before committing descriptors.""" + if artifacts is None: + return None + if len({item.id for item in artifacts}) != len(artifacts): + raise ValueError("Text representations must be unique") + if {item.part_index for item in artifacts} != indexed_parts: + raise ValueError("Text representations must cover exactly indexed parts") + for item in artifacts: + item.storage_key(sync_id, generation) + return [item.model_dump(mode="json") for item in artifacts] diff --git a/backend/airweave/domains/entities/canonical/text_models.py b/backend/airweave/domains/entities/canonical/text_models.py new file mode 100644 index 0000000000..2a9afa2617 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/text_models.py @@ -0,0 +1,91 @@ +"""Derived text descriptors; source originals remain the canonical authority.""" + +from typing import Literal +from uuid import UUID, uuid5 + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class TextArtifact(BaseModel): + """Body-free immutable descriptor, owned by one projection generation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + part_index: int = Field(ge=0) + sha256: str = Field(pattern=r"^[a-f0-9]{64}$") + size_bytes: int = Field(ge=0) + characters: int = Field(ge=0) + content_start: int | None = Field(default=None, ge=0) + kind: Literal["native_text", "extracted_text", "generated_text"] + + @model_validator(mode="before") + @classmethod + def legacy_kind(cls, value): + """Older manifests encoded only converter-vs-generated provenance.""" + if isinstance(value, dict) and "kind" not in value: + return { + **value, + "kind": ( + "extracted_text" if value.get("content_start") is not None else "generated_text" + ), + } + return value + + @model_validator(mode="after") + def boundary(self): + """The content boundary must lie inside the retained indexing text.""" + if (self.kind == "generated_text") != (self.content_start is None): + raise ValueError("Text provenance disagrees with content boundary") + if self.content_start is not None and self.content_start > self.characters: + raise ValueError("Content boundary exceeds representation length") + return self + + def storage_key(self, sync_id: UUID, generation: UUID) -> str: + """No caller/provider-supplied storage path is accepted.""" + if self.id != representation_id(generation, self.part_index): + raise ValueError("Representation identity does not belong to this generation") + return f"canonical/{sync_id}/projections/{generation}/{self.id}/{self.sha256}" + + +def representation_id(generation: UUID, part_index: int) -> UUID: + """Opaque stable reference within this immutable publication generation.""" + return uuid5(generation, f"text:{part_index}") + + +class TextRepresentation(BaseModel): + """Public reference with explicit derived provenance, never a storage key.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + record_id: UUID + revision: int + generation: UUID + pipeline_version: int + part_key: str + kind: Literal["native_text", "extracted_text", "generated_text"] + media_type: Literal["text/markdown"] = "text/markdown" + content_characters: int | None + index_characters: int + source_anchors: Literal["unavailable"] = "unavailable" + + +class TextRepresentationList(BaseModel): + """Unknown covers generations built before text retention, not an empty document.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + record_id: UUID + revision: int + status: Literal["available", "unavailable"] + representations: tuple[TextRepresentation, ...] + + +class TextRead(BaseModel): + """Unicode character range in the selected derived representation view.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + representation: TextRepresentation + view: Literal["content", "index"] + offset: int + total_characters: int + text: str + next_offset: int | None diff --git a/backend/airweave/domains/entities/canonical/text_query.py b/backend/airweave/domains/entities/canonical/text_query.py new file mode 100644 index 0000000000..76d406ce2e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/text_query.py @@ -0,0 +1,225 @@ +"""Current-publication text reads, with the same visibility fence as search.""" + +import hashlib +from typing import Literal +from uuid import UUID + +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import publication_matches +from airweave.domains.entities.canonical.query import ( + BlobUnavailable, + CanonicalQueryService, + RecordNotFound, + StaleRecordRevision, +) +from airweave.domains.entities.canonical.store import CanonicalStoreError +from airweave.domains.entities.canonical.text_models import ( + TextArtifact, + TextRead, + TextRepresentation, + TextRepresentationList, +) +from airweave.domains.storage.exceptions import StorageException +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES +from airweave.domains.storage.protocols import StorageBackend +from airweave.models.entity import Entity +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.sync import Sync + + +class TextUnavailable(CanonicalStoreError): + """No retained text at this exact current publication; refresh/reproject explicitly.""" + + code = "text_unavailable" + + +class CanonicalTextReader: + """Derived text has no independent authority or provider access.""" + + def __init__(self, records: CanonicalQueryService, storage: StorageBackend): + """Reuse record authorization and the configured blob backend.""" + self.records, self.storage = records, storage + + async def _snapshot( + self, + db: AsyncSession, + organization: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + ) -> tuple[ProjectionGeneration | None, tuple[TextArtifact, ...]]: + record = await self.records.read(db, organization, sync_id, record_id) + if record.deleted_at is not None or record.content_access != "available": + raise RecordNotFound("Record content is not currently available") + if record.revision != revision: + raise StaleRecordRevision("Record changed; reread before requesting text") + row = await db.scalar( + select(ProjectionGeneration) + .join( + Entity, + Entity.indexed_generation == ProjectionGeneration.id, + ) + .where( + Entity.id == record_id, + Entity.sync_id == sync_id, + Entity.organization_id == organization, + ProjectionGeneration.organization_id == organization, + ProjectionGeneration.sync_id == sync_id, + ProjectionGeneration.record_id == record_id, + ProjectionGeneration.revision == revision, + ) + .execution_options(populate_existing=True) + ) + if row is None or row.text_representations is None: + return None, () + try: + artifacts = tuple( + TextArtifact.model_validate(item) for item in row.text_representations + ) + coverage = ExtractionCoverage.model_validate(row.extraction_coverage) + if len({item.id for item in artifacts}) != len(artifacts): + raise ValueError("Duplicate artifact") + if {item.part_index for item in artifacts} != { + part.part_index for part in coverage.parts if part.outcome == "indexed" + }: + raise ValueError("Artifact coverage mismatch") + for artifact in artifacts: + artifact.storage_key(sync_id, row.id) + except (ValidationError, ValueError): + raise TextUnavailable("Retained text metadata is unavailable") from None + if artifacts: + # Exact coverage membership above covers every descriptor. The remaining + # publication/ancestor predicate is common to the whole generation. + artifact = artifacts[0] + locator = ProjectionLocator( + record_id=record_id, + revision=revision, + pipeline_version=row.pipeline_version, + generation=row.id, + part_index=artifact.part_index, + ) + visible = await db.scalar( + select(Entity.id) + .join(Sync) + .where( + Entity.organization_id == organization, + Entity.sync_id == sync_id, + Sync.organization_id == organization, + publication_matches(locator), + ) + ) + if visible is None: + return None, () + return row, artifacts + + @staticmethod + def _describe(row: ProjectionGeneration, artifact: TextArtifact) -> TextRepresentation: + coverage = ExtractionCoverage.model_validate(row.extraction_coverage) + part = next(part for part in coverage.parts if part.part_index == artifact.part_index) + return TextRepresentation( + id=artifact.id, + record_id=row.record_id, + revision=row.revision, + generation=row.id, + pipeline_version=row.pipeline_version, + part_key=part.key, + kind=artifact.kind, + content_characters=( + artifact.characters - artifact.content_start + if artifact.content_start is not None + else None + ), + index_characters=artifact.characters, + ) + + async def list( + self, + db: AsyncSession, + organization: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + ) -> TextRepresentationList: + """Describe current retained text without reading blobs or claiming legacy coverage.""" + row, artifacts = await self._snapshot(db, organization, sync_id, record_id, revision) + return TextRepresentationList( + record_id=record_id, + revision=revision, + status="available" if row is not None and artifacts else "unavailable", + representations=tuple(self._describe(row, item) for item in artifacts), + ) + + async def read( + self, + db: AsyncSession, + organization: UUID, + sync_id: UUID, + record_id: UUID, + revision: int, + generation: UUID, + representation_id: UUID, + *, + offset: int = 0, + limit: int = 12000, + view: Literal["content", "index"] = "content", + ) -> TextRead: + """Default content view excludes metadata; generated-only text requires index view.""" + if offset < 0 or not 1 <= limit <= 100000: + raise TextUnavailable("Text range must have nonnegative offset and limit 1–100000") + row, artifacts = await self._snapshot(db, organization, sync_id, record_id, revision) + artifact = next((item for item in artifacts if item.id == representation_id), None) + if row is None or row.id != generation or artifact is None: + raise TextUnavailable("Text publication changed or is not available; reread the record") + if view == "content" and artifact.content_start is None: + raise TextUnavailable("This is generated search text; select its index view explicitly") + description = self._describe(row, artifact) + text = await self._read_verified(artifact, sync_id, generation) + db.expire_all() + current, current_artifacts = await self._snapshot( + db, + organization, + sync_id, + record_id, + revision, + ) + if current is None or current.id != generation or artifact not in current_artifacts: + raise TextUnavailable("Text publication changed during read; reread the record") + if view == "content": + text = text[artifact.content_start :] + if offset > len(text): + raise TextUnavailable("Text offset exceeds this representation") + end = min(len(text), offset + limit) + return TextRead( + representation=description, + view=view, + offset=offset, + total_characters=len(text), + text=text[offset:end], + next_offset=end if end < len(text) else None, + ) + + async def _read_verified(self, artifact: TextArtifact, sync_id: UUID, generation: UUID) -> str: + """Bound storage reads and verify UTF-8, digest and both recorded lengths.""" + if artifact.size_bytes > MAX_FILE_SIZE_BYTES: + raise BlobUnavailable("Retained text exceeds its storage bound") + try: + raw = await self.storage.read_file( + artifact.storage_key(sync_id, generation), + max_bytes=artifact.size_bytes, + ) + if ( + len(raw) != artifact.size_bytes + or hashlib.sha256(raw).hexdigest() != artifact.sha256 + ): + raise ValueError("Text digest mismatch") + text = raw.decode("utf-8") + if len(text) != artifact.characters: + raise ValueError("Text character count mismatch") + except (StorageException, ValueError): + raise BlobUnavailable("Retained text bytes are unavailable; retry later") from None + return text diff --git a/backend/airweave/domains/entities/canonical/workspace_docs.py b/backend/airweave/domains/entities/canonical/workspace_docs.py new file mode 100644 index 0000000000..b6e62b1b1e --- /dev/null +++ b/backend/airweave/domains/entities/canonical/workspace_docs.py @@ -0,0 +1,165 @@ +"""Read retained native Docs content; no provider access or independent identity.""" + +from __future__ import annotations + +import json + +from pydantic import BaseModel, ConfigDict, JsonValue + +from airweave.domains.entities.canonical.blob_materializer import read_blob +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.sources.records.workspace_manifest import ( + WorkspaceManifestV1, + parse_manifest, + validate_document, +) + + +class DocumentReadError(ValueError): + """The retained document cannot supply the requested representation.""" + + +class CapturedDocument(BaseModel): + """Original native structure with validated capture provenance, never reconstructed JSON.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + manifest: WorkspaceManifestV1 + document: dict[str, JsonValue] + + def tab(self, tab_id: str) -> dict[str, JsonValue]: + """Return the unchanged native subtree for one exact captured tab ID.""" + for tab in _tabs(self.document["tabs"]): + properties = _object(tab.get("tabProperties")) + if properties.get("tabId") == tab_id: + return tab + raise DocumentReadError("Requested tab is not present in this captured document") + + def text(self) -> str: + """Project supported authored text across all tabs and ancillary content.""" + if self.manifest.native.status != "complete": + raise DocumentReadError("Native document text capture is incomplete") + sections = [] + for tab in _tabs(self.document["tabs"]): + properties = _object(tab.get("tabProperties")) + title = properties.get("title") + if isinstance(title, str) and title: + sections.append(title + "\n") + document = _object(tab.get("documentTab")) + sections.append(_content(_object(document.get("body")).get("content", []))) + for field in ("headers", "footers", "footnotes"): + for value in _object(document.get(field, {})).values(): + sections.append(_content(_object(value).get("content", []))) + return "\n".join(part for part in sections if part) + + +def _object(value: JsonValue) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise DocumentReadError("Captured Docs structure requires an object") + return value + + +def _array(value: JsonValue) -> list[JsonValue]: + if not isinstance(value, list): + raise DocumentReadError("Captured Docs structure requires an array") + return value + + +def _tabs(value: JsonValue): + for item in _array(value): + tab = _object(item) + yield tab + yield from _tabs(tab.get("childTabs", [])) + + +def _content(value: JsonValue) -> str: + sections = [] + for item in _array(value): + element = _object(item) + if "paragraph" in element: + sections.append(_paragraph(_object(element["paragraph"]))) + elif "table" in element: + table = _object(element["table"]) + for row in _array(table.get("tableRows", [])): + cells = _array(_object(row).get("tableCells", [])) + sections.append("\t".join(_content(_object(c).get("content", [])) for c in cells)) + elif "tableOfContents" in element: + sections.append(_content(_object(element["tableOfContents"]).get("content", []))) + elif "sectionBreak" not in element: + raise DocumentReadError("Unsupported captured Docs structural element") + return "\n".join(sections) + + +def _paragraph(paragraph: dict[str, JsonValue]) -> str: + pieces = [] + for item in _array(paragraph.get("elements", [])): + element = _object(item) + if "textRun" in element: + text = _object(element["textRun"]).get("content") + if not isinstance(text, str): + raise DocumentReadError("Captured Docs text run has no text") + pieces.append(text) + elif "person" in element: + properties = _object(_object(element["person"]).get("personProperties")) + pieces.append(_display_text(properties.get("name") or properties.get("email"))) + elif "richLink" in element: + properties = _object(_object(element["richLink"]).get("richLinkProperties")) + pieces.append(_display_text(properties.get("title"))) + elif "equation" in element: + raise DocumentReadError("Google Docs does not expose equation text for projection") + elif any( + key in element + for key in ( + "inlineObjectElement", + "pageBreak", + "columnBreak", + "footnoteReference", + "horizontalRule", + "autoText", + ) + ): + # Object bytes and automatic page numbers are not authored paragraph text. + # Footnote text is included separately from its documentTab collection. + continue + else: + raise DocumentReadError("Unsupported captured Docs paragraph element") + return "".join(pieces) + + +def _display_text(value: JsonValue) -> str: + if not isinstance(value, str) or not value: + raise DocumentReadError("Captured Docs smart chip has no display text") + return value + + +async def read_document(record: SourceRecord, storage: StorageBackend) -> CapturedDocument: + """Resolve only the current record's committed manifest and document bytes.""" + if record.content_access != "available" or record.deleted_at is not None: + raise DocumentReadError("Captured document is unavailable") + if record.identity.record_type != "file" or record.payload.get("mimeType") != ( + "application/vnd.google-apps.document" + ): + raise DocumentReadError("Captured file is not a Google document") + manifests = [blob for blob in record.blobs if blob.role == "representation_manifest"] + if len(manifests) != 1: + raise DocumentReadError("Captured document requires one representation manifest") + version = record.payload.get("version") + if not isinstance(version, str): + raise DocumentReadError("Captured document has no Drive version") + manifest = parse_manifest( + await read_blob(record, manifests[0], storage), + file_id=record.identity.native_id, + drive_version=version, + blobs=record.blobs, + ) + digest = manifest.native.document_blob + if digest is None: + raise DocumentReadError("Native document content was not captured") + matches = [blob for blob in record.blobs if blob.sha256 == digest] + if len(matches) != 1: + raise DocumentReadError("Native document blob reference is ambiguous or absent") + document = json.loads(await read_blob(record, matches[0], storage)) + if not isinstance(document, dict): + raise DocumentReadError("Native document response is not an object") + validate_document(document, file_id=record.identity.native_id) + return CapturedDocument(manifest=manifest, document=document) diff --git a/backend/airweave/domains/entities/canonical/workspace_sheets.py b/backend/airweave/domains/entities/canonical/workspace_sheets.py new file mode 100644 index 0000000000..8301a57f46 --- /dev/null +++ b/backend/airweave/domains/entities/canonical/workspace_sheets.py @@ -0,0 +1,147 @@ +"""Validated native spreadsheet reads from committed Drive-owned representations.""" + +import json + +from pydantic import BaseModel, ConfigDict, JsonValue + +from airweave.domains.entities.canonical.blob_materializer import read_blob +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.sources.records.sheets_manifest import ( + SHEETS_MIME, + GridBounds, + GridPart, + WorkspaceManifestV2, + parse_sheet_manifest, +) +from airweave.platform.sources.records.sheets_models import ( + SheetGrid, + SpreadsheetCell, + a1_range, + column_name, + metadata_shape, + validate_grid, + validate_partition, +) + + +class CapturedGrid(BaseModel): + """One requested native response paired with its admitted bounds.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + part: GridPart + data: dict[str, JsonValue] + + +class CapturedSpreadsheet(BaseModel): + """One exact stored file revision with validated structured coverage.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + manifest: WorkspaceManifestV2 + spreadsheet: dict[str, JsonValue] + grids: tuple[CapturedGrid, ...] + + def cells(self, bounds: GridBounds) -> tuple[SpreadsheetCell, ...]: + """Return only retained cells in the selected rectangle, with coverage separately.""" + sheets = metadata_shape(self.spreadsheet, self.manifest.file_id).sheets + selected = next((s for s in sheets if s.properties.sheetId == bounds.sheet_id), None) + if selected is None or selected.properties.gridProperties is None: + raise ValueError("Requested sheet is absent or non-grid") + dims = selected.properties.gridProperties + if bounds.end_row > dims.rowCount or bounds.end_column > dims.columnCount: + raise ValueError("Requested range exceeds sheet dimensions") + result = [] + for grid in self.grids: + if grid.part.bounds.sheet_id == bounds.sheet_id: + result.extend(_selected_cells(grid.data, bounds)) + return tuple(result) + + def text(self) -> str: + """One searchable representation; native formula expressions remain in originals.""" + if self.manifest.native.status != "complete" or len(self.grids) != len( + self.manifest.native.parts + ): + raise ValueError("Partial native grids cannot replace the full export projection") + lines = [] + for sheet in metadata_shape(self.spreadsheet, self.manifest.file_id).sheets: + title = sheet.properties.title + lines.append(title) + for grid in self.grids: + if grid.part.bounds.sheet_id == sheet.properties.sheetId: + lines.append(a1_range(title, grid.part.bounds)) + lines.extend(_grid_text(grid.data)) + return "\n".join(lines) + + +def _grid_text(data: dict[str, JsonValue]): + for block in SheetGrid.model_validate(data["sheets"][0]).data: + for offset, row in enumerate(block.rowData): + values = [] + for cell in row.values: + value = cell.get("formattedValue", "") + note = cell.get("note", "") + if not isinstance(value, str) or not isinstance(note, str): + raise ValueError("Malformed native spreadsheet display text") + values.append(value + (" [Note: " + note + "]" if note else "")) + address = f"{column_name(block.startColumn)}{block.startRow + offset + 1}" + yield address + ": " + "\t".join(values) + + +def _selected_cells(grid: dict[str, JsonValue], bounds: GridBounds): + for block in SheetGrid.model_validate(grid["sheets"][0]).data: + for offset, row in enumerate(block.rowData): + row_index = block.startRow + offset + if not bounds.start_row <= row_index < bounds.end_row: + continue + for offset, cell in enumerate(row.values): + column = block.startColumn + offset + if bounds.start_column <= column < bounds.end_column: + yield SpreadsheetCell(row=row_index, column=column, value=cell) + + +def intersects(first: GridBounds, second: GridBounds) -> bool: + """Select retained rectangles without expanding sparse grids into dense cell arrays.""" + return ( + first.sheet_id == second.sheet_id + and max(first.start_row, second.start_row) < min(first.end_row, second.end_row) + and max(first.start_column, second.start_column) < min(first.end_column, second.end_column) + ) + + +async def read_spreadsheet( + record: SourceRecord, + storage: StorageBackend, + *, + bounds: GridBounds | None = None, + for_projection: bool = False, +) -> CapturedSpreadsheet: + """Resolve only admitted blobs and validate all native range identities.""" + if record.content_access != "available" or record.deleted_at is not None: + raise ValueError("Captured spreadsheet is unavailable") + if record.identity.record_type != "file" or record.payload.get("mimeType") != SHEETS_MIME: + raise ValueError("Captured file is not a spreadsheet") + manifests = [blob for blob in record.blobs if blob.role == "representation_manifest"] + if len(manifests) != 1 or not isinstance(record.payload.get("version"), str): + raise ValueError("Native spreadsheet has not been captured") + manifest = parse_sheet_manifest( + await read_blob(record, manifests[0], storage), + file_id=record.identity.native_id, + drive_version=record.payload["version"], + blobs=record.blobs, + ) + by_digest = {blob.sha256: blob for blob in record.blobs} + metadata = json.loads( + await read_blob(record, by_digest[manifest.native.metadata_blob], storage) + ) + validate_partition(manifest, metadata) + grids = [] + for part in manifest.native.parts: + if not ( + (for_projection and manifest.native.status == "complete") + or (bounds is not None and intersects(part.bounds, bounds)) + ): + continue + grid = json.loads(await read_blob(record, by_digest[part.blob], storage)) + validate_grid(grid, manifest.file_id, part.bounds) + grids.append(CapturedGrid(part=part, data=grid)) + return CapturedSpreadsheet(manifest=manifest, spreadsheet=metadata, grids=tuple(grids)) diff --git a/backend/airweave/domains/native_ingestion/README.md b/backend/airweave/domains/native_ingestion/README.md new file mode 100644 index 0000000000..caf1db3b7a --- /dev/null +++ b/backend/airweave/domains/native_ingestion/README.md @@ -0,0 +1,229 @@ +# Native snapshot admission + +This service retains versioned Almanac snapshots in the existing canonical +store. Backend-key HTTP endpoints own source provisioning and the import +lifecycle; they do not enumerate Almanac data. Almanac remains the authority +for content and access. + +The source API binds `short_name=almanac` and +`config_fields={owner_id, dataset}`. Datasets are `knowledge` and `sessions`. +The service checks that binding against the authenticated organization, stored +writer fence and every snapshot. It does not require a fabricated Composio +account. Internal commands are not authorization credentials; HTTP callers +cannot supply writer fences. + +`NativeIngestionService` owns one existing `UnitOfWork`. +`NativeIngestionStore` composes `CanonicalRecordStore._fenced_sync` and +`_capture_locked` inside that transaction. Those existing internal methods are +the only locking/capture dependencies. No table, independent journal, or +alternate revision allocator is introduced. + +Admission loads the batch's exact record identities and parent attestations in +one query under that writer lock. Canonical capture still owns its per-record +writes; batching admission does not bypass its revision or visibility checks. + +Retained payloads explicitly identify their authority and snapshot provenance, +contain the unchanged original JSON, and retain the authoritative version even +for tombstones. Knowledge versions are scalar record revisions. Session and +message snapshots carry metadata and original-content revisions as a partial +order; crossed components are rejected. Replay epochs are not transcript +versions. Changed messages, including tombstones, must match their attested session +version, including a parent submitted in the same batch. A deleted parent permits +matching message tombstones but never message upserts. Submit parents before children. + +An equal version is accepted only for an identical snapshot; retries do not +restore withdrawn visibility. Lower versions, conflicting equal versions and +malformed retained state fail closed. Local scope withdrawal already preserves +the canonical payload, so it also preserves native version state. Higher versions +cannot reopen locally withdrawn records; use the explicit access observation +operation below. Parent access gates remain canonical-owned. + +This is record admission, not proof of a complete session transcript or complete +dataset. Pagination, final snapshot completion, deletion discovery, authoritative +export validation, and source renewal remain work for the publisher boundary. +An empty original JSON tombstone is valid but must carry an authoritative version. +The publisher must not invent a version from capture time or an observation count. + +Tests use the existing disposable PostgreSQL fixture, including actual migration +schemas and transaction rollback. They do not contact a provider or production. + +## Atomic snapshot pages + +`IngestNativePage` composes native admission with the existing canonical scan +transaction. `CanonicalScanStore.page` accepts an internal `LockedPageCapture` +implementation; it is code-owned behavior, never a request-supplied callback. +The scan still owns scope validation, CAS, continuation and final-page state. +Native `admit_locked` returns typed changed observations and unchanged record IDs. +An unchanged record in a new sweep must pass current parent-version and active +visibility checks before its sighting is stamped. This changes neither its +canonical revision nor its payload and never restores withdrawn content. + +Ordinary exact `ingest` retries still acknowledge retained data without claiming +fresh membership. Snapshot pages enumerate active upserts only; authoritative +tombstones continue through ordinary ingestion. This page operation is not a +changes-feed contract. Empty pages are valid; only `final=true` begins the existing +reconciliation phase, and source completion remains a separate cycle barrier. +Admission, capture, sightings and continuation either commit together or roll back. + +HTTP authentication, receipts, provisioning and completion/cancellation are +implemented below, including explicit record access renewal. Authoritative +publisher completeness and automatic source revalidation remain pending. Native jobs cannot be cancelled through the generic +provider route: the guard checks the actual job's sync identity. + +## Native search projection + +`projection.py` supplies offline inputs to the existing canonical projector. +It validates original IDs, revisions and timestamps against their snapshot, +then selects native text without replacing the retained original JSON: + +- Knowledge: body, title, description and user notes. Path is a locator; renaming + it keeps record identity. Selected structured fields also enter index metadata: person contacts, roles, + education and relationship labels; organisation domains/industry; place address + and coordinates; event schedule; creative-work kind/date; profile links. + Reference IDs are retained but never resolved into invented searchable names. + Citation metadata and arbitrary extra JSON remain outside the text projection. +- Sessions: title and description. Archived sessions remain included, matching + current Almanac session search; archived knowledge is excluded. +- Messages: original string content or `type=text` blocks, with session/message + IDs, ordinals and block anchors. Inactive legacy imports are excluded. API + replay payloads, reasoning fields and arbitrary metadata are not search text. + +Mixed messages can publish their text while retained nontext blocks report +`unsupported_format` and partial extraction coverage. Malformed text fails +explicitly. Retaining an image URL in original JSON does not capture image bytes +or make them downloadable; attachment acquisition is still separate work. + +Native indexed sources use the same publication and access validation as provider +sources, without a provider registry entry or fabricated OAuth connection. This +is an index capability, not an authentication grant. The source API binds the +owner; a trusted authoritative publisher is still required for real ingestion. + +## Trusted source and import identity + +Backend-key `PUT /native/sources` ensures the server-derived organization/owner/ +dataset identity in one collection; `GET /native/sources/{source_id}` reads it. +Concurrent requests serialize through the existing Sync primary key. Collection +changes and unexplained partial state conflict; an exact ensure never reopens +withdrawn availability. This setup is not proof of imported or indexed content. + +`NativeImportStore` owns the start/read transaction behind the import HTTP API. A bounded request key derives an existing SyncJob primary +key. Exact retries recover the original state, even after cancellation or source +withdrawal; they never activate another writer. A changed body conflicts. New +imports require an available source and cannot replace another active writer. +Job creation, canonical writer activation and cycle creation commit together. +A new key after a terminal job may replace its incomplete cycle only when the +retained cycle is associated with that preceding native import. Retained records +remain until the subsequent declared scans reconcile them. + +Publisher-declared bounded snapshots use discovery-only completion policies; +complete dataset snapshots use exhaustive policies. This declaration does not +itself certify completeness. The HTTP lifecycle verifies final scopes and +receipts; the publisher must establish stable export boundaries. Explicit record +access renewal is available under an active import as described below; source-wide +credential renewal and automatic source revalidation remain separate. + +## Import HTTP and page receipts + +Backend-key PUT/GET `/native/sources/{source_id}/imports/{request_key}` expose +start and recovery. Keys are opaque nonsecret identifiers: application logs and +analytics redact native paths/parameters, but infrastructure access logs may +still record request URLs. Validation and unexpected exceptions do not log +native request contents through the application middleware. + +`NativePageStore.commit` is the transaction behind PUT `/pages`. +It resolves stored writer authority, admits +the page, advances its cursor and retains one bounded receipt in the existing +scan continuation. No second journal or original-content copy is created. Exact +retries of the current page return its acknowledgement. Changed bodies, old CAS +versions, changed reconciliation state and cancelled writers conflict rather +than reapply writes. Cursors are bounded to32KiB, leaving receipt space inside +the existing64KiB continuation limit. Capture acknowledgement is distinct from +index publication or whole-import completion. + +## Scope HTTP workflow + +Under an active import, PUT `/scopes` begins or resumes an exact scope; +POST `/scopes/read` reads its composite identity from the body without mutation; +PUT `/pages` commits a page; POST `/scopes/reconcile` advances bounded +reconciliation after the final page. Responses use `bounded`/`complete` coverage +and an independent phase, hide writer credentials and internal receipt digests, +and retain the caller cursor plus last acknowledgement for recovery. Parent +epochs are resolved under the server's writer lock. A new import can replace a +prior-cycle scan using its locked version; same-cycle restarts require explicit CAS. + +Bounded native imports use observed membership: only parents seen in their +current completed inventory can admit child scopes or require transcripts for +completion. Unseen retained sessions remain available but are not fresh members. +Default provider membership and checkpoint digests are unchanged. Bounded scope +completion never becomes evidence of exhaustive account discovery. Scope reads +currently require an active import; durable terminal summaries and historical +scope availability are still part of import finalization work. + +### Native import terminal outcomes + +The backend-only import API now supports `POST .../imports/{request_key}/complete` +and `/cancel`. Completion validates all scopes required by the declared capture +policy under the writer lock, then stores the cycle completion, job status, and a +bounded terminal summary in one transaction. A bounded import completing does not +mean the entire account was captured. Summary `indexing: not_verified` explicitly +separates capture from downstream search publication. + +An identical completed retry returns its retained summary even after another import +starts. Cancelling an already-terminal import preserves its outcome and never +cancels the newer writer. A cancelled job immediately fails writer-fence checks. +Terminal scope reads remain available only while the reusable scan row still has +that import's cycle ID; once reused, the API reports unavailable/superseded instead +of showing another import's progress. The import's terminal summary remains durable. +Request keys are opaque, nonsecret identifiers: application diagnostics redact them, +but deployment access logs require their own policy. + + +## Explicit record access observations + +Backend-only GET/POST +`/native/sources/{source_id}/imports/{request_key}/records/{record_id}/access` +read or change current access. GET returns identity, retained revision, current parent +visibility epoch, availability and removal reason, never original content. It works for a terminal import, but is +a current-state read, not historical state at that import. Availability includes +source authentication, active sync, record deletion and ancestor visibility. + +POST requires an active server-held writer and `expected_revision` from the retained +record. Renewal of a message also requires `expected_parent_epoch` from the access +read. Parent withdrawal/restoration invalidates old child evidence even if the child +revision did not change. Roots use a null parent epoch. `action: withdraw` takes a +confirmed `access_revoked` or `scope_removed` reason. It preserves original native payload/version and blob references while +journaling withdrawal through canonical capture. A search miss or incomplete scan +is not valid evidence. An ambiguous source404 requires provider/source interpretation. + +`action: renew` supplies a freshly read, owner-attested active NativeSnapshot for +the exact record. Native owner, identity, version ordering and parent-version checks +still apply. Equal native version requires identical content; it can now explicitly +renew access at the expected local revision. Ordinary imports/page retries cannot. +Newer ordinary child updates also cannot restore inherited unavailability; they require the same explicit renewal path. +Restore parents first, then freshly attest children; restoration advances parent +visibility so previous child attestations remain hidden until individually renewed. +Source-wide authentication is not changed by this operation. + +Read retained revision *before* obtaining fresh source evidence. If another mutation +wins meanwhile, CAS fails. After a lost response, GET current state; do not blindly +change the expected revision and resend old evidence. This operation uses ordinary +CAS recovery rather than a second idempotency journal. An already-active unchanged +renewal may be a no-op. Access observations do not mark records seen in inventory; +normal page capture still owns scan progress/completeness. + +No new table, migration, scheduler or source credential exists here. NativeImports +owns the transaction; NativeAccessStore composes native admission and shared +canonical revision/journal/visibility machinery. The trusted publisher remains +responsible for obtaining actual source evidence and scheduling revalidation. + + +### Structured-field projection rollout + +Changes to this mapper do not recapture or revise originals. Existing unchanged +records need a scoped pipeline-version increment using the existing +`backend/scripts/reproject_canonical.py` dry-run/apply workflow. Read the source's +current version first; target exactly the next version and retry the same pair +on uncertain delivery. Apply only to selected native sources after installing the +new mapper in their worker. Wait for publication completion before claiming those +fields are searchable. Fresh captures use the current mapper. The content read +continues to return the original body; the index read includes prepared metadata. diff --git a/backend/airweave/domains/native_ingestion/__init__.py b/backend/airweave/domains/native_ingestion/__init__.py new file mode 100644 index 0000000000..91b7093d82 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/__init__.py @@ -0,0 +1 @@ +"""Trusted native snapshots; Almanac remains the content and access authority.""" diff --git a/backend/airweave/domains/native_ingestion/access_models.py b/backend/airweave/domains/native_ingestion/access_models.py new file mode 100644 index 0000000000..9b39b33d0b --- /dev/null +++ b/backend/airweave/domains/native_ingestion/access_models.py @@ -0,0 +1,42 @@ +"""Explicit source-access observations, separate from native content versions.""" + +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import Field + +from airweave.domains.entities.canonical.requests import RecordIdentity, ScopeRemovalReason +from airweave.domains.native_ingestion.models import NativeModel, NativeSnapshot + + +class WithdrawNativeRecord(NativeModel): + """Publisher attests exact source access loss, never an incomplete-list absence.""" + + action: Literal["withdraw"] = "withdraw" + expected_revision: int = Field(strict=True, ge=1) + reason: ScopeRemovalReason + + +class RenewNativeRecord(NativeModel): + """Publisher freshly read this original after inspecting the retained revision.""" + + action: Literal["renew"] = "renew" + expected_revision: int = Field(strict=True, ge=1) + expected_parent_epoch: int | None = Field(default=None, strict=True, ge=1) + snapshot: NativeSnapshot + + +NativeAccessChange = Annotated[ + WithdrawNativeRecord | RenewNativeRecord, Field(discriminator="action") +] + + +class NativeRecordAccess(NativeModel): + """Current state for CAS/recovery, without retained original bodies or credentials.""" + + record_id: UUID + identity: RecordIdentity + revision: int + parent_visibility_epoch: int | None + available: bool + removal_reason: str | None diff --git a/backend/airweave/domains/native_ingestion/access_store.py b/backend/airweave/domains/native_ingestion/access_store.py new file mode 100644 index 0000000000..c39c09447c --- /dev/null +++ b/backend/airweave/domains/native_ingestion/access_store.py @@ -0,0 +1,155 @@ +"""Compose native access CAS with the canonical journal and parent visibility rules.""" + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord +from airweave.domains.entities.canonical.store import content_is_available, source_record +from airweave.domains.native_ingestion.access_models import ( + NativeAccessChange, + NativeRecordAccess, + WithdrawNativeRecord, +) +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.models import IngestNativeBatch +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.models.entity import Entity +from airweave.models.sync import SyncStatus + + +class NativeAccessStore: + """The caller owns the transaction; no independent access journal or retry state.""" + + def __init__(self, imports: NativeImportStore): + """Reuse import locks, native admission and canonical capture.""" + self.imports = imports + self.ingestion = NativeIngestionStore(imports.canonical) + + async def _record( + self, db: AsyncSession, organization_id: UUID, sync_id: UUID, record_id: UUID + ) -> Entity: + row = await db.scalar( + select(Entity) + .where( + Entity.id == record_id, + Entity.organization_id == organization_id, + Entity.sync_id == sync_id, + Entity.record_revision > 0, + ) + .execution_options(populate_existing=True) + ) + if row is None: + raise NativeAdmissionError("Native record is unavailable in this source") + return row + + async def _parent_epoch(self, db: AsyncSession, row: Entity) -> int | None: + """Native messages have one session parent; no arbitrary ancestor token scheme.""" + if row.parent_record_type is None: + return None + record = source_record(row) + assert record.parent is not None + return await db.scalar( + select(Entity.visibility_epoch).where( + Entity.organization_id == row.organization_id, + Entity.sync_id == row.sync_id, + Entity.entity_definition_short_name == record.parent.record_type, + Entity.entity_id == record.parent.entity_key, + Entity.record_revision > 0, + ) + ) + + async def read( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + record_id: UUID, + ) -> NativeRecordAccess: + """Read current CAS state even when the original is inaccessible or import terminal.""" + bound, _, _ = await self.imports.load(db, organization_id, source_id, request_key) + row = await self._record(db, organization_id, bound.sync.id, record_id) + record = source_record(row) + available = await db.scalar(select(content_is_available()).where(Entity.id == row.id)) + return NativeRecordAccess( + record_id=row.id, + identity=record.identity, + revision=row.record_revision, + parent_visibility_epoch=await self._parent_epoch(db, row), + available=bool(available) + and row.deleted_at is None + and bound.source.is_authenticated + and bound.sync.status == SyncStatus.ACTIVE, + removal_reason=row.removal_reason, + ) + + async def change( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + record_id: UUID, + request: NativeAccessChange, + ) -> NativeRecordAccess: + """Fresh source evidence and retained revision must agree under the active writer.""" + imported = await self.imports.active(db, organization_id, source_id, request_key) + sync = await self.imports.canonical._fenced_sync(db, imported.fence) + row = await self._record(db, organization_id, sync.id, record_id) + if row.record_revision != request.expected_revision: + raise NativeAdmissionError("Native record changed; read current access before retrying") + record = source_record(row) + now = datetime.now(timezone.utc) + if isinstance(request, WithdrawNativeRecord): + # Access is a local observation. Preserve original content/version and blobs. + observations = ( + CaptureRecord( + identity=record.identity, + parent=record.parent, + payload=record.payload, + payload_schema_version=record.payload_schema_version, + content_hash=record.content_hash, + completeness=record.completeness, + blobs=record.blobs, + kind="delete", + removal_reason=request.reason, + observed_at=now, + source_created_at=record.source_created_at, + source_updated_at=record.source_updated_at, + ), + ) + else: + parent_epoch = await self._parent_epoch(db, row) + if request.expected_parent_epoch != parent_epoch or ( + record.parent is not None and parent_epoch is None + ): + raise NativeAdmissionError( + "Native parent access changed; obtain fresh source evidence" + ) + if ( + request.snapshot.identity != record.identity + or request.snapshot.operation != "upsert" + ): + raise NativeAdmissionError("Renewal requires this exact active native original") + admitted = await self.ingestion.admit_locked( + db, + sync, + IngestNativeBatch( + fence=imported.fence, + observed_at=now, + snapshots=(request.snapshot,), + ), + revalidated_ids=(row.id,), + ) + observations = admitted.records + await self.imports.canonical._capture_locked( + db, + sync, + CaptureBatch(fence=imported.fence, records=observations), + mark_seen=False, + ) + return await self.read(db, organization_id, source_id, request_key, record_id) diff --git a/backend/airweave/domains/native_ingestion/errors.py b/backend/airweave/domains/native_ingestion/errors.py new file mode 100644 index 0000000000..a3b0f91e09 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/errors.py @@ -0,0 +1,15 @@ +"""Stable native admission failures without source content in diagnostics.""" + +from airweave.domains.entities.canonical.store import CanonicalStoreError + + +class NativeAdmissionError(CanonicalStoreError): + """A native source, version or retained state cannot safely admit this write.""" + + code = "native_admission_failed" + + +class NativeImportNotFound(NativeAdmissionError): + """No import exists at the authenticated source/request identity.""" + + code = "native_import_not_found" diff --git a/backend/airweave/domains/native_ingestion/import_models.py b/backend/airweave/domains/native_ingestion/import_models.py new file mode 100644 index 0000000000..22575a7650 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/import_models.py @@ -0,0 +1,81 @@ +"""Native import intent and durable identity, without client-controlled writer fences.""" + +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, Field, model_validator + +from airweave.core.shared_models import SyncJobStatus +from airweave.domains.entities.canonical.coverage_models import CompletionPolicy +from airweave.domains.entities.canonical.requests import WriterFence +from airweave.domains.native_ingestion.models import NativeModel + + +class StartNativeImport(NativeModel): + """Publisher attestation; bounded roots do not authorize root absence deletion.""" + + snapshot_id: str = Field(min_length=1, max_length=256) + coverage: Literal["bounded", "complete"] + transcript_coverage: Literal["bounded", "complete"] = Field( + default="bounded", + description="Complete message enumeration for each selected session; sessions only", + ) + + def completion_policy(self, record_type: str) -> CompletionPolicy: + """Complete transcripts own only children of the selected session roots.""" + if self.coverage == "complete" or ( + record_type == "message" and self.transcript_coverage == "complete" + ): + return "exhaustive" + return "discovery_only" + + +class NativeImportSummary(NativeModel): + """Durable capture outcome; indexing completion is separately observed.""" + + outcome: Literal["completed", "cancelled"] + coverage: Literal["bounded", "complete"] + finished_at: AwareDatetime + sequence: int | None = Field(default=None, ge=0) + completed_scopes: int = Field(ge=0) + capture_complete: bool + indexing: Literal["not_verified"] = "not_verified" + + @model_validator(mode="after") + def consistent_outcome(self) -> "NativeImportSummary": + """Malformed retained outcomes must not become contradictory public claims.""" + completed = self.outcome == "completed" + if self.capture_complete != completed or (completed and self.sequence is None): + raise ValueError("Native terminal summary contradicts its capture outcome") + return self + + +class NativeImportReceipt(NativeModel): + """Server-only job metadata; an identical request never reactivates a writer.""" + + schema_version: Literal[1] = 1 + source_id: UUID + request_key: str = Field(min_length=1, max_length=128) + request: StartNativeImport + fence: WriterFence + cycle_id: UUID + summary: NativeImportSummary | None = None + + @model_validator(mode="after") + def consistent_coverage(self) -> "NativeImportReceipt": + """A terminal result cannot expand the publisher's original scope claim.""" + if self.summary is not None and self.summary.coverage != self.request.coverage: + raise ValueError("Native terminal coverage contradicts import intent") + return self + + +class NativeImportState(NativeModel): + """Public identity and execution state; running is not evidence of full capture.""" + + source_id: UUID + import_id: UUID + request_key: str + request: StartNativeImport + status: SyncJobStatus + cycle_id: UUID + summary: NativeImportSummary | None = None diff --git a/backend/airweave/domains/native_ingestion/import_service.py b/backend/airweave/domains/native_ingestion/import_service.py new file mode 100644 index 0000000000..a060ecc8da --- /dev/null +++ b/backend/airweave/domains/native_ingestion/import_service.py @@ -0,0 +1,141 @@ +"""Atomic native import start/read application boundary.""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.native_ingestion.access_models import NativeAccessChange, NativeRecordAccess +from airweave.domains.native_ingestion.access_store import NativeAccessStore +from airweave.domains.native_ingestion.import_models import NativeImportState, StartNativeImport +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.page_models import CommitNativePage, NativePageAck +from airweave.domains.native_ingestion.page_store import NativePageStore +from airweave.domains.native_ingestion.scope_models import ( + BeginNativeScope, + NativeScopeRef, + NativeScopeState, + ReconcileNativeScope, +) +from airweave.domains.native_ingestion.scope_store import NativeScopeStore +from airweave.domains.native_ingestion.store import NativeIngestionStore + + +class NativeImports: + """Own the transaction; never enqueue provider execution or expose writer fences.""" + + def __init__(self, store: NativeImportStore): + """Inject the native import persistence boundary.""" + self.store = store + self.access = NativeAccessStore(store) + self.scopes = NativeScopeStore(store) + self.pages = NativePageStore(store, NativeIngestionStore(store.canonical)) + + async def start( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: StartNativeImport, + ) -> NativeImportState: + """Create or recover a durable import and commit its writer/cycle atomically.""" + async with UnitOfWork(db): + return await self.store.start(db, organization_id, source_id, request_key, request) + + async def read( + self, db: AsyncSession, organization_id: UUID, source_id: UUID, request_key: str + ) -> NativeImportState: + """Read durable progress under source authorization without reactivation.""" + async with UnitOfWork(db): + return await self.store.read(db, organization_id, source_id, request_key) + + async def begin_scope( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: BeginNativeScope, + ) -> NativeScopeState: + """Authorize and execute begin scope in one transaction.""" + async with UnitOfWork(db): + return await self.scopes.begin(db, organization_id, source_id, request_key, request) + + async def read_scope( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: NativeScopeRef, + ) -> NativeScopeState: + """Authorize and execute read scope in one transaction.""" + async with UnitOfWork(db): + return await self.scopes.read(db, organization_id, source_id, request_key, request) + + async def reconcile_scope( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: ReconcileNativeScope, + ) -> NativeScopeState: + """Authorize and execute reconcile scope in one transaction.""" + async with UnitOfWork(db): + return await self.scopes.reconcile(db, organization_id, source_id, request_key, request) + + async def page( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: CommitNativePage, + ) -> NativePageAck: + """Authorize and execute page in one transaction.""" + async with UnitOfWork(db): + return await self.pages.commit(db, organization_id, source_id, request_key, request) + + async def finish( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + *, + cancel: bool = False, + ) -> NativeImportState: + """Finalize capture and its durable outcome in one transaction.""" + async with UnitOfWork(db): + return await self.store.finish( + db, organization_id, source_id, request_key, cancel=cancel + ) + + async def read_access( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + record_id: UUID, + ) -> NativeRecordAccess: + """Read retained revision and current availability without disclosing original content.""" + async with UnitOfWork(db): + return await self.access.read(db, organization_id, source_id, request_key, record_id) + + async def change_access( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + record_id: UUID, + request: NativeAccessChange, + ) -> NativeRecordAccess: + """Commit source access evidence and publication invalidation atomically.""" + async with UnitOfWork(db): + return await self.access.change( + db, organization_id, source_id, request_key, record_id, request + ) diff --git a/backend/airweave/domains/native_ingestion/import_store.py b/backend/airweave/domains/native_ingestion/import_store.py new file mode 100644 index 0000000000..54c656248c --- /dev/null +++ b/backend/airweave/domains/native_ingestion/import_store.py @@ -0,0 +1,290 @@ +"""Native import start/recovery in the existing source writer transaction.""" + +import hashlib +import json +from datetime import datetime, timezone +from uuid import UUID, uuid4, uuid5 + +from pydantic import ValidationError +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.core.shared_models import SyncStatus +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, + RestartCycle, +) +from airweave.domains.entities.canonical.cycle_store import ( + begin_cycle, + complete_cycle, + cursor_row, + cycle_state, + restart_cycle, +) +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.errors import NativeAdmissionError, NativeImportNotFound +from airweave.domains.native_ingestion.import_models import ( + NativeImportReceipt, + NativeImportState, + NativeImportSummary, + StartNativeImport, +) +from airweave.domains.native_ingestion.source_store import LockedNativeSource, NativeSourceStore +from airweave.models.capture_scan import CaptureScan +from airweave.models.sync_job import SyncJob + +# Persisted v1 identity: changing this namespace would break retry recovery. +IMPORT_NAMESPACE = UUID("8aab29e2-c0a6-40cd-a540-5316f1b6c4b3") + + +def native_import_id(source_id: UUID, request_key: str) -> UUID: + """Unambiguous, source-scoped identity backed by the existing job primary key.""" + if not 1 <= len(request_key) <= 128: + raise NativeAdmissionError("Import request key must contain 1 to 128 characters") + return uuid5(IMPORT_NAMESPACE, json.dumps([str(source_id), request_key], separators=(",", ":"))) + + +def receipt(job: SyncJob, source_id: UUID, request_key: str) -> NativeImportReceipt: + """Reject unrelated jobs and malformed state instead of adopting or restarting them.""" + try: + value = NativeImportReceipt.model_validate(job.sync_metadata) + except ValidationError as error: + raise NativeAdmissionError("Native import receipt is malformed") from error + if ( + ( + value.summary is not None + and ( + value.summary.outcome != job.status + or value.summary.coverage != value.request.coverage + or value.summary.capture_complete != (job.status == "completed") + ) + ) + or value.source_id != source_id + or value.request_key != request_key + or value.fence.job_id != job.id + or value.fence.sync_id != job.sync_id + or value.fence.organization_id != job.organization_id + ): + raise NativeAdmissionError("Native import receipt does not match its job") + return value + + +def import_state(job: SyncJob, value: NativeImportReceipt) -> NativeImportState: + """Never return the internal writer fence to the publisher.""" + return NativeImportState( + source_id=value.source_id, + import_id=job.id, + request_key=value.request_key, + request=value.request, + status=job.status, + cycle_id=value.cycle_id, + summary=value.summary, + ) + + +class NativeImportStore: + """No commits: caller owns one UnitOfWork for job, writer and cycle creation.""" + + def __init__(self, sources: NativeSourceStore, canonical: CanonicalRecordStore): + """Reuse native source ownership and canonical writer/cycle machinery.""" + self.sources = sources + self.canonical = canonical + + async def start( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: StartNativeImport, + ) -> NativeImportState: + """Recover exact requests, otherwise create one fenced snapshot import.""" + bound = await self.sources.require(db, organization_id, source_id) + if request.transcript_coverage == "complete" and bound.binding.dataset != "sessions": + raise NativeAdmissionError("Complete transcript coverage requires a sessions source") + job_id = native_import_id(source_id, request_key) + job = await db.scalar(select(SyncJob).where(SyncJob.id == job_id).with_for_update()) + if job is not None: + if job.sync_id != bound.sync.id or job.organization_id != organization_id: + raise NativeAdmissionError("Import job belongs to another source") + saved = receipt(job, source_id, request_key) + if saved.request != request: + raise NativeAdmissionError("Import request key has conflicting intent") + return import_state(job, saved) + + if not bound.source.is_authenticated or bound.sync.status != SyncStatus.ACTIVE: + raise NativeAdmissionError("Native source is unavailable for import") + previous_job_id = bound.sync.writer_job_id + job = SyncJob( + id=job_id, + sync_id=bound.sync.id, + organization_id=organization_id, + provisioning_generation=bound.sync.provisioning_generation, + status="running", + started_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) + db.add(job) + await db.flush() + fence = await self.canonical.activate_writer( + db, + organization_id, + bound.sync.id, + job_id, + attempt_id=uuid4(), + attempt_number=1, + ) + plan = {"native_import_id": str(job_id), **request.model_dump(mode="json")} + fingerprint = hashlib.sha256(json.dumps(plan, sort_keys=True).encode()).hexdigest() + parents = ( + {"knowledge": (None,)} + if bound.binding.dataset == "knowledge" + else {"session": (None,), "message": ("session",)} + ) + configuration = CycleConfiguration( + fingerprint=fingerprint, + parents=parents, + completion_policies={kind: request.completion_policy(kind) for kind in parents}, + membership="observed" if request.coverage == "bounded" else "retained", + ) + previous = cycle_state(await cursor_row(db, fence)) + if previous is not None and previous.source_plan.get("native_import_id") != str( + previous_job_id + ): + raise NativeAdmissionError("Existing capture cycle belongs to another import lifecycle") + if previous is not None and previous.phase == "active": + # Activation already rejects a different live writer. A new import after + # a terminal job explicitly replaces its incomplete enumeration only. + cycle = await restart_cycle( + db, + RestartCycle( + fence=fence, + expected=previous.version, + configuration=configuration, + source_plan=plan, + ), + ) + else: + cycle = await begin_cycle( + db, + BeginCycle( + fence=fence, + expected=previous.version if previous else None, + configuration=configuration, + source_plan=plan, + ), + ) + saved = NativeImportReceipt( + source_id=source_id, + request_key=request_key, + request=request, + fence=fence, + cycle_id=cycle.version.cycle_id, + ) + job.sync_metadata = saved.model_dump(mode="json") + await db.flush() + return import_state(job, saved) + + async def read( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + ) -> NativeImportState: + """Authorize actual source/job association, including terminal import retries.""" + _, job, saved = await self.load(db, organization_id, source_id, request_key) + return import_state(job, saved) + + async def active( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + ) -> NativeImportReceipt: + """Resolve stored authority for a mutation, never a publisher-supplied fence.""" + bound, _, saved = await self.load(db, organization_id, source_id, request_key) + if not bound.source.is_authenticated or bound.sync.status != SyncStatus.ACTIVE: + raise NativeAdmissionError("Native source is unavailable for import") + await self.canonical._fenced_sync(db, saved.fence) + return saved + + async def load( + self, db: AsyncSession, organization_id: UUID, source_id: UUID, request_key: str + ) -> tuple[LockedNativeSource, SyncJob, NativeImportReceipt]: + """Authorize and lock the actual job, even after its writer became terminal.""" + bound = await self.sources.require(db, organization_id, source_id) + job = await db.scalar( + select(SyncJob) + .where( + SyncJob.id == native_import_id(source_id, request_key), + SyncJob.sync_id == bound.sync.id, + SyncJob.organization_id == organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if job is None: + raise NativeImportNotFound("Native import does not exist") + return bound, job, receipt(job, source_id, request_key) + + async def finish( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + *, + cancel: bool = False, + ) -> NativeImportState: + """Persist capture completion or cancellation without changing another writer.""" + bound, job, saved = await self.load(db, organization_id, source_id, request_key) + if job.status in ("completed", "cancelled", "failed"): + if cancel or (job.status == "completed" and saved.summary is not None): + return import_state(job, saved) + raise NativeAdmissionError("Import is terminal without successful capture completion") + if cancel: + # Even an older job may be cancelled; only its status fences its writes. + outcome = "cancelled" + else: + if not bound.source.is_authenticated or bound.sync.status != SyncStatus.ACTIVE: + raise NativeAdmissionError("Native source is unavailable for import") + await self.canonical._fenced_sync(db, saved.fence) + current = cycle_state(await cursor_row(db, saved.fence)) + if current is None or current.version.cycle_id != saved.cycle_id: + raise NativeAdmissionError("Import no longer owns its capture cycle") + await complete_cycle( + db, bound.sync, CompleteCycle(fence=saved.fence, expected=current.version) + ) + outcome = "completed" + finished_at = datetime.now(timezone.utc) + count = await db.scalar( + select(func.count()) + .select_from(CaptureScan) + .where( + CaptureScan.organization_id == organization_id, + CaptureScan.sync_id == bound.sync.id, + CaptureScan.cycle_id == saved.cycle_id, + CaptureScan.phase == "complete", + ) + ) + # A superseded cancellation must not claim the newer writer's sequence. + sequence = ( + bound.sync.observed_change_sequence if bound.sync.writer_job_id == job.id else None + ) + summary = NativeImportSummary( + outcome=outcome, + coverage=saved.request.coverage, + finished_at=finished_at, + sequence=sequence, + completed_scopes=count, + capture_complete=not cancel, + ) + saved = saved.model_copy(update={"summary": summary}) + job.sync_metadata = saved.model_dump(mode="json") + job.status = outcome + job.completed_at = finished_at.replace(tzinfo=None) + await db.flush() + return import_state(job, saved) diff --git a/backend/airweave/domains/native_ingestion/knowledge_fields.py b/backend/airweave/domains/native_ingestion/knowledge_fields.py new file mode 100644 index 0000000000..a3aa6f78b2 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/knowledge_fields.py @@ -0,0 +1,123 @@ +"""Searchable native wiki fields, independent of body text and reference resolution.""" + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictStr + + +class _Fields(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class _Link(_Fields): + label: StrictStr + url: StrictStr + handle: StrictStr | None = None + + +class _Role(_Fields): + title: StrictStr + start: StrictStr | None = None + end: StrictStr | None = None + + +class _Education(_Fields): + degree: StrictStr | None = None + subject: StrictStr | None = None + start: StrictStr | None = None + end: StrictStr | None = None + + +class _RelatedPerson(_Fields): + relationship: StrictStr + + +class _Person(_Fields): + related_people: tuple[_RelatedPerson, ...] = () + emails: tuple[StrictStr, ...] = () + phones: tuple[StrictStr, ...] = () + timezone: StrictStr | None = None + roles: tuple[_Role, ...] = () + experience: tuple[_Role, ...] = () + education: tuple[_Education, ...] = () + links: tuple[_Link, ...] = () + + +class _Organisation(_Fields): + related_people: tuple[_RelatedPerson, ...] = () + domains: tuple[StrictStr, ...] = () + website: StrictStr | None = None + industry: tuple[StrictStr, ...] = () + founded: StrictStr | None = None + links: tuple[_Link, ...] = () + + +class _Address(_Fields): + street: StrictStr | None = None + locality: StrictStr | None = None + region: StrictStr | None = None + postal_code: StrictStr | None = None + country: StrictStr | None = None + + +class _Coordinates(_Fields): + latitude: float = Field(ge=-90, le=90, allow_inf_nan=False) + longitude: float = Field(ge=-180, le=180, allow_inf_nan=False) + + +class _Place(_Fields): + coordinates: _Coordinates | None = None + place_kind: StrictStr | None = None + address: _Address | None = None + + +class _Timed(_Fields): + kind: Literal["timed"] + start: StrictStr + end: StrictStr + timezone: StrictStr + + +class _AllDay(_Fields): + kind: Literal["all_day"] + start_on: StrictStr + end_on_exclusive: StrictStr + + +class _Event(_Fields): + schedule: Annotated[_Timed | _AllDay, Field(discriminator="kind")] | None = None + + +class _CreativeWork(_Fields): + work_kind: StrictStr | None = None + published_on: StrictStr | None = None + + +def _text(value: _Fields) -> str: + """Flatten only the validated allowlist, never arbitrary native payload metadata.""" + lines = [] + for key, item in value.model_dump(exclude_none=True).items(): + label = key.replace("_", " ") + if isinstance(item, str): + lines.append(f"{label}: {item}") + elif isinstance(item, list | tuple): + for entry in item: + if isinstance(entry, str): + lines.append(f"{label}: {entry}") + elif entry: + lines.append(f"{label}: " + "; ".join(f"{k}: {v}" for k, v in entry.items())) + elif isinstance(item, dict) and item: + lines.append(f"{label}: " + "; ".join(f"{k}: {v}" for k, v in item.items())) + return "\n".join(lines) + + +def knowledge_details(kind: str, original: dict[str, JsonValue]) -> str | None: + """Do not fetch referenced records or invent names from their opaque IDs.""" + model = { + "person": _Person, + "organisation": _Organisation, + "place": _Place, + "creative_work": _CreativeWork, + "event": _Event, + }.get(kind) + return _text(model.model_validate(original)) or None if model else None diff --git a/backend/airweave/domains/native_ingestion/models.py b/backend/airweave/domains/native_ingestion/models.py new file mode 100644 index 0000000000..3d7a0b4198 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/models.py @@ -0,0 +1,120 @@ +"""Versioned native snapshots, distinct from provider observations and originals.""" + +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, model_validator + +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, + ScanVersion, + WriterFence, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation + + +class NativeModel(BaseModel): + """Strict immutable boundary for publisher-owned data.""" + + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + +class RecordVersion(NativeModel): + """Almanac knowledge record revision, independent of capture revision.""" + + kind: Literal["record"] = "record" + revision: int = Field(strict=True, ge=1) + + +class SessionVersion(NativeModel): + """Metadata and original-content revisions; replay epochs are not history.""" + + kind: Literal["session"] = "session" + revision: int = Field(strict=True, ge=1) + content_revision: int = Field(strict=True, ge=0) + + +NativeVersion = Annotated[RecordVersion | SessionVersion, Field(discriminator="kind")] + + +class NativeSourceBinding(NativeModel): + """Server-provisioned owner and dataset; never supplied by an untrusted reader.""" + + owner_id: str = Field(min_length=1) + dataset: Literal["knowledge", "sessions"] + + +class NativeSnapshot(NativeModel): + """An attested copy of native JSON, not a second editable authority.""" + + authority: Literal["almanac"] = "almanac" + representation: Literal["snapshot"] = "snapshot" + schema_version: Literal[1] = 1 + owner_id: str = Field(min_length=1) + identity: RecordIdentity + parent: RecordIdentity | None = None + version: NativeVersion + operation: Literal["upsert", "delete"] = "upsert" + original: dict[str, JsonValue] + source_created_at: AwareDatetime | None = None + source_updated_at: AwareDatetime | None = None + + @model_validator(mode="after") + def identity_contract(self) -> "NativeSnapshot": + """Keep version domains and session child identity explicit.""" + if self.identity.record_type == "knowledge": + if self.version.kind != "record" or self.parent is not None: + raise ValueError("Knowledge snapshots require record versions and no parent") + elif self.identity.record_type == "session": + if self.version.kind != "session" or self.parent is not None: + raise ValueError("Session snapshots require session versions and no parent") + elif self.identity.record_type == "message": + if ( + self.version.kind != "session" + or self.parent is None + or self.parent.record_type != "session" + or self.parent.container_id is not None + or self.identity.container_id != self.parent.native_id + ): + raise ValueError("Original messages require their session parent and version") + else: + raise ValueError("Unsupported native snapshot record type") + if self.identity.record_type != "message" and self.identity.container_id is not None: + raise ValueError("Native roots have no container") + return self + + +class IngestNativeBatch(NativeModel): + """Internal service command; the authenticated edge must issue the writer fence.""" + + fence: WriterFence + observed_at: AwareDatetime + snapshots: tuple[NativeSnapshot, ...] = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def unique_records(self) -> "IngestNativeBatch": + """One version per identity makes atomic admission unambiguous.""" + keys = [(item.identity.record_type, item.identity.entity_key) for item in self.snapshots] + if len(set(keys)) != len(keys): + raise ValueError("A native batch must contain unique record identities") + return self + + +class NativeAdmission(NativeModel): + """Admitted changes and exact retained retries under the current writer lock.""" + + records: tuple[CaptureRecord, ...] + unchanged_ids: tuple[UUID, ...] + + +class IngestNativePage(IngestNativeBatch): + """Native originals plus the existing scan CAS; empty final pages are valid.""" + + snapshots: tuple[NativeSnapshot, ...] = Field(max_length=500) + scope: CompletedScope + cycle_id: UUID + expected: ScanVersion + continuation: ScanContinuation + final: bool = False diff --git a/backend/airweave/domains/native_ingestion/page_models.py b/backend/airweave/domains/native_ingestion/page_models.py new file mode 100644 index 0000000000..3cc4f3870f --- /dev/null +++ b/backend/airweave/domains/native_ingestion/page_models.py @@ -0,0 +1,56 @@ +"""Bounded publisher page commands and durable acknowledgements.""" + +import json +from typing import Literal +from uuid import UUID + +from pydantic import Field, JsonValue, field_validator, model_validator + +from airweave.domains.entities.canonical.requests import CompletedScope, ScanVersion +from airweave.domains.native_ingestion.models import NativeModel, NativeSnapshot + + +class CommitNativePage(NativeModel): + """One in-flight page per scope; preserve this exact body on an uncertain retry.""" + + page_id: UUID + scope: CompletedScope + expected: ScanVersion + snapshots: tuple[NativeSnapshot, ...] = Field(max_length=500) + cursor: dict[str, JsonValue] = Field(default_factory=dict) + final: bool = False + + @field_validator("cursor") + @classmethod + def bounded_cursor(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + """Leave space for the receipt inside the existing 64KiB scan continuation.""" + if len(json.dumps(value, allow_nan=False).encode()) > 32768: + raise ValueError("Native page cursor exceeds 32KiB") + return value + + @model_validator(mode="after") + def unique_snapshots(self) -> "CommitNativePage": + """Reject duplicate originals before attempting any page writes.""" + keys = [(item.identity.record_type, item.identity.entity_key) for item in self.snapshots] + if len(keys) != len(set(keys)): + raise ValueError("Native page contains duplicate originals") + return self + + +class NativePageAck(NativeModel): + """Durable capture acknowledgement, not search publication or import completion.""" + + page_id: UUID + version: ScanVersion + phase: Literal["collecting", "reconciling", "complete"] + sequence: int + changed: int + unchanged: int + + +class NativePageReceipt(NativeModel): + """Only the most recently committed page is retryable with identical success.""" + + schema_version: Literal[1] = 1 + digest: str = Field(pattern=r"^[a-f0-9]{64}$") + acknowledgement: NativePageAck diff --git a/backend/airweave/domains/native_ingestion/page_store.py b/backend/airweave/domains/native_ingestion/page_store.py new file mode 100644 index 0000000000..375f8c5892 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/page_store.py @@ -0,0 +1,100 @@ +"""Atomic page admission and bounded lost-response recovery on existing scan state.""" + +import hashlib +import json +from datetime import datetime, timezone +from uuid import UUID + +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore, ScanConflict +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.models import IngestNativePage +from airweave.domains.native_ingestion.page_models import ( + CommitNativePage, + NativePageAck, + NativePageReceipt, +) +from airweave.domains.native_ingestion.store import NativeIngestionStore + +_RECEIPT = "native_page_receipt" + + +class NativePageStore: + """Caller owns one transaction; no side journal and no retained response payload copies.""" + + def __init__(self, imports: NativeImportStore, ingestion: NativeIngestionStore): + """Share the existing canonical writer and scan boundary.""" + self.imports = imports + self.ingestion = ingestion + self.scans = CanonicalScanStore(ingestion.canonical) + + async def commit( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: CommitNativePage, + ) -> NativePageAck: + """A committed current page retry returns its receipt without another capture.""" + imported = await self.imports.active(db, organization_id, source_id, request_key) + current = await self.scans.read(db, imported.fence, request.scope) + if current is None: + raise NativeAdmissionError("Native scope has not been started") + if current.cycle_id != imported.cycle_id: + raise NativeAdmissionError("Scope belongs to another native import") + digest = hashlib.sha256( + json.dumps( + request.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode() + ).hexdigest() + stored = current.continuation.value.get(_RECEIPT) + if stored is not None: + try: + previous = NativePageReceipt.model_validate(stored) + except ValidationError as error: + raise NativeAdmissionError("Native page receipt is malformed") from error + if previous.acknowledgement.page_id == request.page_id: + if previous.digest != digest or previous.acknowledgement.version != current.version: + raise ScanConflict("Page retry conflicts with committed progress") + return previous.acknowledgement + if current.version != request.expected: + raise ScanConflict("Page changed; read committed scope progress before retrying") + result = await self.ingestion.page( + db, + IngestNativePage( + fence=imported.fence, + observed_at=datetime.now(timezone.utc), + snapshots=request.snapshots, + scope=request.scope, + cycle_id=imported.cycle_id, + expected=request.expected, + continuation=ScanContinuation(value={"cursor": request.cursor}), + final=request.final, + ), + ) + ack = NativePageAck( + page_id=request.page_id, + version=result.state.version, + phase=result.state.phase, + sequence=result.capture.sequence, + changed=len(result.capture.changes), + unchanged=result.capture.unchanged, + ) + # The scan owns this row and its lock; receipt and page share the outer commit. + row = await self.scans._row(db, imported.fence, request.scope) + assert row is not None # Existing scan remains locked through this transaction. + row.continuation = { + "cursor": request.cursor, + _RECEIPT: NativePageReceipt(digest=digest, acknowledgement=ack).model_dump(mode="json"), + } + await db.flush() + return ack diff --git a/backend/airweave/domains/native_ingestion/projection.py b/backend/airweave/domains/native_ingestion/projection.py new file mode 100644 index 0000000000..55ad69c04d --- /dev/null +++ b/backend/airweave/domains/native_ingestion/projection.py @@ -0,0 +1,221 @@ +"""Offline views of original Almanac records, sessions and protocol messages.""" + +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, StrictBool + +from airweave.domains.entities.canonical.extraction_models import ExtractionPart +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_inputs import ProjectionInput, ProjectionInputs +from airweave.domains.native_ingestion.knowledge_fields import knowledge_details +from airweave.domains.native_ingestion.models import NativeSnapshot +from airweave.domains.sync_pipeline.pipeline.text_models import NativeTextBody +from airweave.domains.sync_pipeline.processors.entity_fields import populate_base_fields +from airweave.platform.entities._airweave_field import AirweaveField +from airweave.platform.entities._base import BaseEntity + + +class _Original(BaseModel): + """Read only declared projection fields; all other original JSON stays retained.""" + + model_config = ConfigDict(extra="ignore") + id: str = Field(min_length=1) + created_at: AwareDatetime + + +class _Knowledge(_Original): + type: Literal["person", "organisation", "place", "event", "creative_work", "topic", "page"] + revision: int = Field(strict=True, ge=1) + title: str = Field(min_length=1) + description: str = Field(min_length=1) + path: str = Field(min_length=1) + body: str + updated_at: AwareDatetime + archived_at: AwareDatetime | None + user_notes: str | None = None + + +class _Session(_Original): + title: str = Field(min_length=1) + description: str = Field(min_length=1) + revision: int = Field(strict=True, ge=1) + content_revision: int = Field(strict=True, ge=0) + archived: StrictBool + updated_at: AwareDatetime + + +class _ImportProvenance(BaseModel): + model_config = ConfigDict(extra="forbid") + legacy_row_id: int = Field(strict=True, gt=0) + active: StrictBool + compacted: StrictBool + + +class _TextBlock(BaseModel): + model_config = ConfigDict(extra="ignore") + type: Literal["text"] + text: str + + +class _Block(BaseModel): + model_config = ConfigDict(extra="allow") + type: str = Field(min_length=1) + + +class _Payload(BaseModel): + model_config = ConfigDict(extra="ignore") + role: Literal["user", "assistant", "tool", "system"] + content: str | list[dict[str, JsonValue]] | None + + +class _Message(_Original): + ordinal: int = Field(strict=True, ge=0) + import_provenance: _ImportProvenance | None = None + payload: _Payload + + +class NativeContentEntity(BaseEntity): + """Explicit searchable metadata; original payloads never enter entity serialization.""" + + native_key: str = AirweaveField(..., is_entity_id=True, embeddable=False) + title: str = AirweaveField(..., is_name=True, embeddable=True) + description: str | None = AirweaveField(None, embeddable=True) + user_notes: str | None = AirweaveField(None, embeddable=True) + details: str | None = AirweaveField(None, embeddable=True) + native_type: str = AirweaveField(..., embeddable=False) + path: str | None = AirweaveField(None, embeddable=False) + session_id: str | None = AirweaveField(None, embeddable=False) + message_id: str | None = AirweaveField(None, embeddable=False) + message_ordinal: int | None = AirweaveField(None, embeddable=False) + block_index: int | None = AirweaveField(None, embeddable=False) + role: str | None = AirweaveField(None, embeddable=False) + archived: bool | None = AirweaveField(None, embeddable=False) + + +def _read(record: SourceRecord) -> tuple[NativeSnapshot, _Knowledge | _Session | _Message]: + snapshot = NativeSnapshot.model_validate(record.payload) + if snapshot.identity != record.identity or snapshot.parent != record.parent: + raise ValueError("Native snapshot identity differs from retained record") + if snapshot.operation != "upsert": + raise ValueError("Native tombstone cannot project content") + match snapshot.identity.record_type: + case "knowledge": + original = _Knowledge.model_validate(snapshot.original) + if snapshot.version.kind != "record" or original.revision != snapshot.version.revision: + raise ValueError("Knowledge version differs from original") + case "session": + original = _Session.model_validate(snapshot.original) + if ( + snapshot.version.kind != "session" + or original.revision != snapshot.version.revision + or original.content_revision != snapshot.version.content_revision + ): + raise ValueError("Session version differs from original") + case "message": + original = _Message.model_validate(snapshot.original) + case _: + raise ValueError("Unsupported native projection type") + _validate_identity(record, snapshot, original) + return snapshot, original + + +def _validate_identity( + record: SourceRecord, snapshot: NativeSnapshot, original: _Knowledge | _Session | _Message +) -> None: + if original.id != snapshot.identity.native_id: + raise ValueError("Native original ID differs from snapshot") + if ( + original.created_at != record.source_created_at + or original.created_at != snapshot.source_created_at + ): + raise ValueError("Native original creation timestamp differs from snapshot") + if isinstance(original, (_Knowledge, _Session)) and ( + original.updated_at != record.source_updated_at + or original.updated_at != snapshot.source_updated_at + ): + raise ValueError("Native original update timestamp differs from snapshot") + + +def excluded_native(record: SourceRecord) -> bool: + """Follow current knowledge and session search visibility policy.""" + _, original = _read(record) + if isinstance(original, _Knowledge): + return original.archived_at is not None + return ( + isinstance(original, _Message) + and original.import_provenance is not None + and not original.import_provenance.active + ) + + +def _part(index: int, key: str, entity: NativeContentEntity, text: str) -> ProjectionInput: + populate_base_fields(entity) + return ProjectionInput( + part=ExtractionPart(part_index=index, key=key, kind="body", media_type="text/plain"), + entity=entity, + native_body=NativeTextBody(text=text), + ) + + +def map_native(record: SourceRecord) -> ProjectionInputs: + """Select verbatim bodies and individual text blocks without flattening protocol JSON.""" + snapshot, original = _read(record) + key = snapshot.identity.native_id + common = { + "created_at": record.source_created_at, + "updated_at": record.source_updated_at, + "breadcrumbs": [], + } + if isinstance(original, _Knowledge): + entity = NativeContentEntity( + native_key=key, + title=original.title, + description=original.description, + user_notes=original.user_notes, + details=knowledge_details(original.type, snapshot.original), + path=original.path, + native_type=original.type, + **common, + ) + return ProjectionInputs(parts=(_part(0, key, entity, original.body),)) + if isinstance(original, _Session): + entity = NativeContentEntity( + native_key=key, + title=original.title, + native_type="session", + session_id=key, + archived=original.archived, + **common, + ) + return ProjectionInputs(parts=(_part(0, key, entity, original.description),)) + content = original.payload.content + blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content + # Null or an empty list is retained nontext protocol content, not invented text. + blocks = blocks or [{"type": "nontext"}] + parts = [] + for index, block in enumerate(blocks): + kind = _Block.model_validate(block).type + part_key = f"{snapshot.identity.entity_key}:block:{index}" + if kind != "text": + parts.append( + ProjectionInput( + part=ExtractionPart(part_index=index, key=part_key, kind="record"), + entity=None, + omission="unsupported_format", + ) + ) + continue + text = _TextBlock.model_validate(block).text + entity = NativeContentEntity( + native_key=part_key, + title=f"{original.payload.role} message", + native_type="message", + session_id=snapshot.identity.container_id, + message_id=original.id, + message_ordinal=original.ordinal, + block_index=index, + role=original.payload.role, + **common, + ) + parts.append(_part(index, part_key, entity, text)) + return ProjectionInputs(parts=tuple(parts)) diff --git a/backend/airweave/domains/native_ingestion/scope_models.py b/backend/airweave/domains/native_ingestion/scope_models.py new file mode 100644 index 0000000000..87d431415b --- /dev/null +++ b/backend/airweave/domains/native_ingestion/scope_models.py @@ -0,0 +1,42 @@ +"""Publisher-visible scope progress, without storage receipts or writer credentials.""" + +from typing import Literal +from uuid import UUID + +from pydantic import Field, JsonValue + +from airweave.domains.entities.canonical.requests import CompletedScope, ScanVersion +from airweave.domains.native_ingestion.models import NativeModel +from airweave.domains.native_ingestion.page_models import NativePageAck + + +class NativeScopeRef(NativeModel): + """Exact native container identity; a read does not start or restart work.""" + + scope: CompletedScope + + +class BeginNativeScope(NativeScopeRef): + """Resume an unchanged scope; explicit same-import restarts require its version.""" + + expected: ScanVersion | None = None + restart: bool = False + + +class ReconcileNativeScope(NativeScopeRef): + """Advance bounded reconciliation only after the final page is committed.""" + + expected: ScanVersion + limit: int = Field(default=250, ge=1, le=500) + + +class NativeScopeState(NativeModel): + """Capture progress; complete refers to this scope's declared coverage policy.""" + + scope: CompletedScope + cycle_id: UUID + version: ScanVersion + phase: Literal["collecting", "reconciling", "complete"] + coverage: Literal["bounded", "complete"] + cursor: dict[str, JsonValue] + last_page: NativePageAck | None = None diff --git a/backend/airweave/domains/native_ingestion/scope_store.py b/backend/airweave/domains/native_ingestion/scope_store.py new file mode 100644 index 0000000000..95b713b447 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/scope_store.py @@ -0,0 +1,121 @@ +"""Native scope lifecycle composed with canonical scope admission and reconciliation.""" + +from datetime import datetime, timezone +from uuid import UUID + +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.cycle_store import attest_cycle, scope_owner +from airweave.domains.entities.canonical.scan_models import BeginScan, ReconcileScan, ScanState +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.page_models import NativePageReceipt +from airweave.domains.native_ingestion.scope_models import ( + BeginNativeScope, + NativeScopeRef, + NativeScopeState, + ReconcileNativeScope, +) + + +def scope_state(state: ScanState) -> NativeScopeState: + """Hide the internal receipt envelope, retaining its bounded last-page acknowledgement.""" + try: + saved = state.continuation.value.get("native_page_receipt") + ack = NativePageReceipt.model_validate(saved).acknowledgement if saved is not None else None + return NativeScopeState( + scope=state.scope, + cycle_id=state.cycle_id, + version=state.version, + phase=state.phase, + coverage="complete" if state.completion_policy == "exhaustive" else "bounded", + cursor=state.continuation.value.get("cursor", {}), + last_page=ack, + ) + except ValidationError as error: + raise NativeAdmissionError("Native scope progress is malformed") from error + + +class NativeScopeStore: + """No commits; import authorization and scope mutation share the outer transaction.""" + + def __init__(self, imports: NativeImportStore): + """Use one canonical scan implementation for native and provider correctness.""" + self.imports = imports + self.scans = CanonicalScanStore(imports.canonical) + + async def begin( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: BeginNativeScope, + ) -> NativeScopeState: + """Derive current parent authority server-side; do not reset same-import scans silently.""" + imported = await self.imports.active(db, organization_id, source_id, request_key) + _, cycle = await attest_cycle(db, imported.fence, imported.cycle_id) + parent = await scope_owner(db, imported.fence, cycle, request.scope) + previous = await self.scans.read(db, imported.fence, request.scope) + expected = request.expected + if expected is None and previous is not None and previous.cycle_id != imported.cycle_id: + # A new import explicitly replaces a preceding cycle, under the same writer lock. + expected = previous.version + state = await self.scans.begin( + db, + BeginScan( + fence=imported.fence, + scope=request.scope, + cycle_id=imported.cycle_id, + fingerprint=cycle.configuration.fingerprint, + expected=expected, + restart=request.restart, + expected_parent_epoch=parent.visibility_epoch if parent else None, + ), + ) + return scope_state(state) + + async def read( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: NativeScopeRef, + ) -> NativeScopeState: + """Read retained scope progress only while its row still belongs to this import.""" + _, _, imported = await self.imports.load(db, organization_id, source_id, request_key) + row = await self.scans._row(db, imported.fence, request.scope) + if row is None or row.cycle_id != imported.cycle_id: + raise NativeAdmissionError("Scope is unavailable or superseded for this import") + state = (await self.scans._state(db, row)).model_copy( + update={ + "completion_policy": imported.request.completion_policy(request.scope.record_type) + } + ) + return scope_state(state) + + async def reconcile( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + request_key: str, + request: ReconcileNativeScope, + ) -> NativeScopeState: + """The canonical completion policy controls whether absence can withdraw records.""" + imported = await self.imports.active(db, organization_id, source_id, request_key) + result = await self.scans.reconcile( + db, + ReconcileScan( + fence=imported.fence, + scope=request.scope, + cycle_id=imported.cycle_id, + expected=request.expected, + observed_at=datetime.now(timezone.utc), + limit=request.limit, + ), + ) + return scope_state(result.state) diff --git a/backend/airweave/domains/native_ingestion/service.py b/backend/airweave/domains/native_ingestion/service.py new file mode 100644 index 0000000000..ae618839c8 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/service.py @@ -0,0 +1,27 @@ +"""Atomic internal native ingestion; no provider I/O or public credential handling.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.models import CaptureResult +from airweave.domains.entities.canonical.scan_models import ScanResult +from airweave.domains.native_ingestion.models import IngestNativeBatch, IngestNativePage +from airweave.domains.native_ingestion.store import NativeIngestionStore + + +class NativeIngestionService: + """Commit admission and canonical capture together, or neither.""" + + def __init__(self, store: NativeIngestionStore): + """Inject persistence; callers supply a fresh session without pending writes.""" + self.store = store + + async def ingest(self, db: AsyncSession, request: IngestNativeBatch) -> CaptureResult: + """Admit one bounded snapshot batch under its current writer fence.""" + async with UnitOfWork(db): + return await self.store.ingest(db, request) + + async def page(self, db: AsyncSession, request: IngestNativePage) -> ScanResult: + """Commit admitted originals, sweep sightings and continuation together.""" + async with UnitOfWork(db): + return await self.store.page(db, request) diff --git a/backend/airweave/domains/native_ingestion/source_models.py b/backend/airweave/domains/native_ingestion/source_models.py new file mode 100644 index 0000000000..0216e14eec --- /dev/null +++ b/backend/airweave/domains/native_ingestion/source_models.py @@ -0,0 +1,48 @@ +"""Immutable native source identity and backend-only ensure/read contracts.""" + +import json +from uuid import UUID, uuid5 + +from pydantic import Field + +from airweave.domains.native_ingestion.models import NativeModel, NativeSourceBinding + +# Versioned protocol constants: changing these would create duplicate source identities. +_SOURCE_NAMESPACE_V1 = UUID("b13e2720-64ab-4ac5-9a0f-6264e2fb783f") +_SYNC_NAMESPACE_V1 = UUID("01b71224-aad4-4a85-914b-a42fc1e8a554") + + +def native_source_id(organization_id: UUID, binding: NativeSourceBinding) -> UUID: + """Organization, owner and dataset define identity; collection is immutable configuration.""" + name = json.dumps( + [str(organization_id), binding.owner_id, binding.dataset], + ensure_ascii=False, + separators=(",", ":"), + ) + return uuid5(_SOURCE_NAMESPACE_V1, name) + + +def native_sync_id(source_id: UUID) -> UUID: + """One sync owns each native source's retained records and writer fence.""" + return uuid5(_SYNC_NAMESPACE_V1, str(source_id)) + + +class EnsureNativeSource(NativeSourceBinding): + """The backend attests the owner; no provider or credential selectors are accepted.""" + + collection: str = Field(min_length=1, max_length=255) + + def binding(self) -> NativeSourceBinding: + """Exclude configuration from stable source identity.""" + return NativeSourceBinding(owner_id=self.owner_id, dataset=self.dataset) + + +class NativeSource(NativeModel): + """Provisioned identifiers, never proof of completed import or indexed coverage.""" + + source_connection_id: UUID + sync_id: UUID + organization_id: UUID + binding: NativeSourceBinding + collection: str + available: bool diff --git a/backend/airweave/domains/native_ingestion/source_service.py b/backend/airweave/domains/native_ingestion/source_service.py new file mode 100644 index 0000000000..9d4e35c3b7 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/source_service.py @@ -0,0 +1,29 @@ +"""Own atomic native binding transactions independently of provider provisioning.""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.native_ingestion.source_models import EnsureNativeSource, NativeSource +from airweave.domains.native_ingestion.source_store import NativeSourceStore + + +class NativeSources: + """Ensure/read only; source availability is not proof of imported content.""" + + def __init__(self, store: NativeSourceStore): + """Inject the single persistence boundary.""" + self.store = store + + async def ensure( + self, db: AsyncSession, organization_id: UUID, request: EnsureNativeSource + ) -> NativeSource: + """Publish the complete source/sync binding or roll back both.""" + async with UnitOfWork(db): + return (await self.store.ensure(db, organization_id, request)).response() + + async def get(self, db: AsyncSession, organization_id: UUID, source_id: UUID) -> NativeSource: + """Read a currently valid binding without opening a provider connection.""" + async with UnitOfWork(db): + return (await self.store.require(db, organization_id, source_id)).response() diff --git a/backend/airweave/domains/native_ingestion/source_store.py b/backend/airweave/domains/native_ingestion/source_store.py new file mode 100644 index 0000000000..baf18b3de4 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/source_store.py @@ -0,0 +1,160 @@ +"""Primary-key serialized native binding; no JSON discovery or external side effects.""" + +from uuid import UUID + +from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict, ValidationError +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.native_ingestion.models import NativeSourceBinding +from airweave.domains.native_ingestion.source_models import ( + EnsureNativeSource, + NativeSource, + native_source_id, + native_sync_id, +) +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +class LockedNativeSource(BaseModel): + """Internal ORM state held under Sync then SourceConnection row locks.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", frozen=True) + sync: Sync + source: SourceConnection + binding: NativeSourceBinding + + def response(self) -> NativeSource: + """Expose only stable identifiers and typed binding, not ORM/auth data.""" + return NativeSource( + source_connection_id=self.source.id, + sync_id=self.sync.id, + organization_id=self.sync.organization_id, + binding=self.binding, + collection=self.source.readable_collection_id, + available=self.source.is_authenticated, + ) + + +class NativeSourceStore: + """All methods run within their caller's transaction; locks live through commit.""" + + async def ensure( + self, db: AsyncSession, organization_id: UUID, request: EnsureNativeSource + ) -> LockedNativeSource: + """Concurrent exact ensures serialize on the deterministic Sync primary key.""" + collection = await db.scalar( + select(Collection.id).where( + Collection.organization_id == organization_id, + Collection.readable_id == request.collection, + ) + ) + if collection is None: + raise HTTPException(404, "Native collection not found") + binding = request.binding() + source_id = native_source_id(organization_id, binding) + sync_id = native_sync_id(source_id) + inserted = await db.scalar( + insert(Sync) + .values( + id=sync_id, + organization_id=organization_id, + name=f"Almanac {binding.dataset}", + status="ACTIVE", + sync_type="full", + ) + .on_conflict_do_nothing(index_elements=[Sync.id]) + .returning(Sync.id) + ) + # The unique insert waits for any concurrent ensure before this lock/read. + sync = await db.scalar( + select(Sync) + .where( + Sync.id == sync_id, + Sync.organization_id == organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if sync is None: + raise HTTPException(409, "Native source identity conflicts with existing state") + if inserted is not None: + existing = await db.scalar( + select(SourceConnection.id).where(SourceConnection.id == source_id) + ) + if existing is not None: + raise HTTPException(409, "Native source identity conflicts with existing state") + db.add( + SourceConnection( + id=source_id, + organization_id=organization_id, + sync_id=sync_id, + name=f"Almanac {binding.dataset}", + short_name="almanac", + config_fields=binding.model_dump(mode="json"), + readable_collection_id=request.collection, + is_authenticated=True, + ) + ) + await db.flush() + locked = await self.require(db, organization_id, source_id, missing_status=409) + if locked.binding != binding or locked.source.readable_collection_id != request.collection: + raise HTTPException(409, "Native source binding is immutable") + return locked + + async def require( + self, + db: AsyncSession, + organization_id: UUID, + source_id: UUID, + *, + missing_status: int = 404, + ) -> LockedNativeSource: + """Lock Sync then source and fail closed on foreign or unexplained partial state.""" + sync = await db.scalar( + select(Sync) + .where( + Sync.id == native_sync_id(source_id), + Sync.organization_id == organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + source = await db.scalar( + select(SourceConnection) + .where( + SourceConnection.id == source_id, + SourceConnection.organization_id == organization_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if sync is None or source is None: + raise HTTPException(missing_status, "Native source not found or incomplete") + try: + binding = NativeSourceBinding.model_validate(source.config_fields) + except ValidationError as error: + raise HTTPException(409, "Native source binding is malformed") from error + if ( + source.short_name != "almanac" + or source.sync_id != sync.id + or native_source_id(organization_id, binding) != source_id + or source.readable_auth_provider_id is not None + or source.auth_provider_config is not None + or source.connection_id is not None + or source.connection_init_session_id is not None + ): + raise HTTPException(409, "Native source identity conflicts with existing state") + collection = await db.scalar( + select(Collection.id).where( + Collection.readable_id == source.readable_collection_id, + Collection.organization_id == organization_id, + ) + ) + if collection is None: + raise HTTPException(409, "Native source collection is unavailable") + return LockedNativeSource(sync=sync, source=source, binding=binding) diff --git a/backend/airweave/domains/native_ingestion/store.py b/backend/airweave/domains/native_ingestion/store.py new file mode 100644 index 0000000000..02062a1476 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/store.py @@ -0,0 +1,291 @@ +"""Native admission composed with the existing canonical writer transaction.""" + +from uuid import UUID + +from pydantic import ValidationError +from sqlalchemy import select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.models import CaptureResult +from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord +from airweave.domains.entities.canonical.scan_models import CommitScanPage, ScanResult +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore, content_is_available +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.models import ( + IngestNativeBatch, + IngestNativePage, + NativeAdmission, + NativeSnapshot, + NativeSourceBinding, + NativeVersion, +) +from airweave.models.entity import Entity +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +def compare_versions(previous: NativeVersion, incoming: NativeVersion) -> int: + """Partial ordering: reject crossed session revisions instead of guessing.""" + if previous.kind != incoming.kind: + raise NativeAdmissionError("Native version domain changed") + old = (previous.revision,) + new = (incoming.revision,) + if previous.kind == "session" and incoming.kind == "session": + old += (previous.content_revision,) + new += (incoming.content_revision,) + lower = any(left < right for left, right in zip(new, old, strict=True)) + higher = any(left > right for left, right in zip(new, old, strict=True)) + if lower and higher: + raise NativeAdmissionError("Native version components are incomparable") + return -1 if lower else 1 if higher else 0 + + +def _admit(entity: Entity, snapshot: NativeSnapshot, *, renew: bool = False) -> bool: + """Validate retained version state; False acknowledges an exact retained retry.""" + try: + previous = NativeSnapshot.model_validate(entity.source_payload) + except ValidationError as error: + raise NativeAdmissionError("Retained native version state is malformed") from error + if previous.identity != snapshot.identity or previous.owner_id != snapshot.owner_id: + raise NativeAdmissionError("Retained native identity does not match its source") + comparison = compare_versions(previous.version, snapshot.version) + if comparison < 0: + raise NativeAdmissionError("Native snapshot version is stale") + if comparison == 0: + if previous != snapshot: + raise NativeAdmissionError("Native snapshot version has conflicting content") + # Ordinary retries acknowledge retention. Only explicit access CAS renews. + return renew + if not renew and entity.removal_reason in ("access_revoked", "scope_removed", "absent"): + raise NativeAdmissionError("Native source visibility requires explicit renewal") + return True + + +def _validate_message_parent( + snapshot: NativeSnapshot, + submitted: dict[tuple[str, str], NativeSnapshot], + retained: dict[tuple[str, str], Entity], +) -> None: + """A late transcript page cannot join a newer session snapshot.""" + if snapshot.identity.record_type != "message": + return + parent = snapshot.parent + assert parent is not None # NativeSnapshot validates message identity before admission. + key = (parent.record_type, parent.entity_key) + attested = submitted.get(key) + if attested is None: + entity = retained.get(key) + try: + attested = NativeSnapshot.model_validate(entity.source_payload if entity else None) + except ValidationError as error: + raise NativeAdmissionError("Message requires an attested native session") from error + if ( + attested.identity != parent + or attested.owner_id != snapshot.owner_id + or (snapshot.operation == "upsert" and attested.operation != "upsert") + or attested.version != snapshot.version + ): + raise NativeAdmissionError("Message version must match its attested native session") + + +def _observation(snapshot: NativeSnapshot, request: IngestNativeBatch) -> CaptureRecord: + return CaptureRecord( + identity=snapshot.identity, + parent=snapshot.parent, + payload=snapshot.model_dump(mode="json"), + kind=snapshot.operation, + removal_reason="provider_deleted" if snapshot.operation == "delete" else None, + observed_at=request.observed_at, + source_created_at=snapshot.source_created_at, + source_updated_at=snapshot.source_updated_at, + ) + + +def _admit_snapshots( + snapshots: tuple[NativeSnapshot, ...], + binding: NativeSourceBinding, + submitted: dict[tuple[str, str], NativeSnapshot], + retained: dict[tuple[str, str], Entity], + request: IngestNativeBatch, + *, + sightings: bool, + revalidated_ids: tuple[UUID, ...] = (), +) -> NativeAdmission: + """Apply version and membership rules to one preloaded bounded batch.""" + accepted = [] + unchanged = [] + for snapshot in snapshots: + if sightings and snapshot.operation != "upsert": + raise NativeAdmissionError("Snapshot pages enumerate active originals, not tombstones") + dataset = "knowledge" if snapshot.identity.record_type == "knowledge" else "sessions" + if snapshot.owner_id != binding.owner_id or dataset != binding.dataset: + raise NativeAdmissionError("Snapshot does not belong to the bound native source") + entity = retained.get((snapshot.identity.record_type, snapshot.identity.entity_key)) + if entity is not None and not _admit(entity, snapshot, renew=entity.id in revalidated_ids): + if sightings: + _validate_message_parent(snapshot, submitted, retained) + if entity.deleted_at is not None: + raise NativeAdmissionError("A new sweep requires active original sightings") + unchanged.append(entity.id) + continue + _validate_message_parent(snapshot, submitted, retained) + accepted.append(_observation(snapshot, request)) + return NativeAdmission(records=tuple(accepted), unchanged_ids=tuple(unchanged)) + + +class NativeIngestionStore: + """Compose canonical internals inside the service's single UnitOfWork. + + `_fenced_sync` acquires the existing writer lock and validates the active run. + `_capture_locked` persists admitted snapshots under that same lock. Neither + method commits; duplicating either would split version checks from capture. + """ + + def __init__(self, canonical: CanonicalRecordStore): + """Reuse canonical locking, visibility and revision publication.""" + self.canonical = canonical + + async def ingest(self, db: AsyncSession, request: IngestNativeBatch) -> CaptureResult: + """Run inside the caller's UnitOfWork; never commit independently.""" + sync = await self.canonical._fenced_sync(db, request.fence) + admitted = await self.admit_locked(db, sync, request) + result = await self.canonical._capture_locked( + db, sync, CaptureBatch(fence=request.fence, records=admitted.records) + ) + return result.model_copy( + update={"unchanged": result.unchanged + len(admitted.unchanged_ids)} + ) + + async def admit_locked( + self, + db: AsyncSession, + sync: Sync, + request: IngestNativeBatch, + *, + sightings: bool = False, + revalidated_ids: tuple[UUID, ...] = (), + ) -> NativeAdmission: + """Caller must hold the canonical writer lock through capture and commit.""" + if ( + sync.id != request.fence.sync_id + or sync.organization_id != request.fence.organization_id + ): + raise NativeAdmissionError("Native admission lock belongs to another source") + source = ( + await db.execute( + select(SourceConnection) + .where( + SourceConnection.organization_id == request.fence.organization_id, + SourceConnection.sync_id == sync.id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + if source is None or source.short_name != "almanac": + raise NativeAdmissionError("Writer is not bound to an Almanac native source") + try: + binding = NativeSourceBinding.model_validate(source.config_fields) + except ValidationError as error: + raise NativeAdmissionError("Native source binding is malformed") from error + submitted = { + (item.identity.record_type, item.identity.entity_key): item + for item in request.snapshots + } + keys = set(submitted) + keys.update( + (item.parent.record_type, item.parent.entity_key) + for item in request.snapshots + if item.parent is not None + ) + # The sync writer lock already serializes mutations. Fetch exact identities + # and parent attestations once rather than adding a round trip per record. + rows = await db.scalars( + select(Entity) + .where( + Entity.organization_id == request.fence.organization_id, + Entity.sync_id == sync.id, + tuple_(Entity.entity_definition_short_name, Entity.entity_id).in_(sorted(keys)), + ) + .execution_options(populate_existing=True) + ) + retained = {(row.entity_definition_short_name, row.entity_id): row for row in rows} + admitted = _admit_snapshots( + request.snapshots, + binding, + submitted, + retained, + request, + sightings=sightings, + revalidated_ids=revalidated_ids, + ) + # A newer original cannot silently rebind a retained child after ancestor + # withdrawal. Explicit renewal attests both its local revision and parent epoch. + protected = tuple( + row.id + for item in admitted.records + if item.kind == "upsert" + and (row := retained.get((item.identity.record_type, item.identity.entity_key))) + is not None + and row.id not in revalidated_ids + ) + if protected: + visible = set( + await db.scalars( + select(Entity.id).where(Entity.id.in_(protected), content_is_available()) + ) + ) + if visible != set(protected): + raise NativeAdmissionError("Native source visibility requires explicit renewal") + unchanged = admitted.unchanged_ids + if sightings and unchanged: + available = set( + await db.scalars( + select(Entity.id).where(Entity.id.in_(unchanged), content_is_available()) + ) + ) + if available != set(unchanged): + raise NativeAdmissionError("Withdrawn native originals cannot be marked seen") + return admitted + + async def page(self, db: AsyncSession, request: IngestNativePage) -> ScanResult: + """Use canonical scan CAS and one capture callback within the caller's transaction.""" + observations = tuple(_observation(item, request) for item in request.snapshots) + + async def capture_page( + session: AsyncSession, sync: Sync, batch: CaptureBatch, *, seen_id: UUID + ) -> CaptureResult: + # CanonicalScanStore has already validated these exact observations/scope. + if batch.records != observations or batch.fence != request.fence: + raise NativeAdmissionError("Native scan capture command changed") + admitted = await self.admit_locked(session, sync, request, sightings=True) + result = await self.canonical._capture_locked( + session, + sync, + CaptureBatch(fence=batch.fence, records=admitted.records), + seen_id=seen_id, + ) + if admitted.unchanged_ids: + await session.execute( + update(Entity) + .where(Entity.id.in_(admitted.unchanged_ids)) + .values(last_seen_run_id=seen_id) + ) + return result.model_copy( + update={"unchanged": result.unchanged + len(admitted.unchanged_ids)} + ) + + return await CanonicalScanStore(self.canonical).page( + db, + CommitScanPage( + fence=request.fence, + scope=request.scope, + cycle_id=request.cycle_id, + expected=request.expected, + records=observations, + continuation=request.continuation, + final=request.final, + ), + capture_page=capture_page, + ) diff --git a/backend/airweave/domains/native_ingestion/tests/__init__.py b/backend/airweave/domains/native_ingestion/tests/__init__.py new file mode 100644 index 0000000000..f633ca572c --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/__init__.py @@ -0,0 +1 @@ +"""Native ingestion tests use package-qualified names alongside canonical tests.""" diff --git a/backend/airweave/domains/native_ingestion/tests/conftest.py b/backend/airweave/domains/native_ingestion/tests/conftest.py new file mode 100644 index 0000000000..e7b95331ff --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/conftest.py @@ -0,0 +1,5 @@ +"""Reuse isolated real canonical migrations, never an application database.""" + +from airweave.domains.entities.canonical.tests.conftest import database, source + +__all__ = ["database", "source"] diff --git a/backend/airweave/domains/native_ingestion/tests/test_access_api.py b/backend/airweave/domains/native_ingestion/tests/test_access_api.py new file mode 100644 index 0000000000..7ac8bacfa6 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_access_api.py @@ -0,0 +1,209 @@ +"""Explicit access lifecycle using HTTP and isolated PostgreSQL, not provider mutations.""" + +# Imported fixture is resolved through pytest parameters. +# ruff: noqa: F811 +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.models import IngestNativeBatch, SessionVersion +from airweave.domains.native_ingestion.service import NativeIngestionService +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.domains.native_ingestion.tests.test_import_api import native_api # noqa: F401 +from airweave.domains.native_ingestion.tests.test_ingestion import NOW, snapshot +from airweave.models.entity import Entity +from airweave.models.source_connection import SourceConnection + + +async def capture(client, base, original): + scope = {"record_type": original.identity.record_type} + if original.parent: + scope.update( + container_id=original.identity.container_id, + parent=original.parent.model_dump(mode="json"), + ) + opened = await client.put(base + "/scopes", json={"scope": scope}) + assert opened.status_code == 200, opened.text + result = await client.put( + base + "/pages", + json={ + "page_id": str(uuid4()), + "scope": scope, + "expected": opened.json()["version"], + "snapshots": [original.model_dump(mode="json")], + "final": True, + }, + ) + assert result.status_code == 200, result.text + completed = await client.post( + base + "/scopes/reconcile", + json={ + "scope": scope, + "expected": result.json()["version"], + }, + ) + assert completed.status_code == 200, completed.text + + +async def row_id(database, source, kind): + async with database() as db: + return await db.scalar( + select(Entity.id).where( + Entity.sync_id == source.sync_id, + Entity.entity_definition_short_name == kind, + ) + ) + + +async def test_access_cas_preserves_original_and_rejects_stale_restore(database, native_api): + client, ctx, source, logs = native_api + base = f"/native/sources/{source.source_connection_id}/imports/access" + await client.put(base, json={"snapshot_id": "one", "coverage": "bounded"}) + original = snapshot(owner_id="owner", revision=3) + await capture(client, base, original) + record_id = await row_id(database, source, "knowledge") + url = base + f"/records/{record_id}/access" + initial = await client.get(url) + assert initial.json()["available"] and initial.json()["revision"] == 1 + assert "original" not in initial.text and "payload" not in initial.text + withdraw = {"action": "withdraw", "expected_revision": 1, "reason": "access_revoked"} + revoked = await client.post(url, json=withdraw) + assert revoked.status_code == 200, revoked.text + assert revoked.json()["revision"] == 2 and not revoked.json()["available"] + # Lost response recovery reads state; old CAS never replays across newer observations. + assert (await client.post(url, json=withdraw)).status_code == 409 + assert (await client.get(url)).json() == revoked.json() + async with database() as db: + retained = await db.get(Entity, record_id) + assert retained.source_payload == original.model_dump(mode="json") + renew = { + "action": "renew", + "expected_revision": 2, + "snapshot": original.model_dump(mode="json"), + } + for changes in ( + {"owner_id": "wrong"}, + {"original": {"body": "same-version-conflict"}}, + {"version": {"kind": "record", "revision": 2}}, + ): + rejected = await client.post(url, json=renew | {"snapshot": renew["snapshot"] | changes}) + assert rejected.status_code == 409, rejected.text + assert (await client.get(url)).json() == revoked.json() + restored = await client.post(url, json=renew) + assert restored.status_code == 200, restored.text + assert restored.json()["available"] and restored.json()["revision"] == 3 + revoked_again = await client.post(url, json=withdraw | {"expected_revision": 3}) + assert revoked_again.json()["revision"] == 4 + assert (await client.post(url, json=renew)).status_code == 409 + assert not (await client.get(url)).json()["available"] + ctx.is_api_key_auth = False + assert (await client.get(url)).status_code == 403 + ctx.is_api_key_auth = True + ctx.organization.id = uuid4() + assert (await client.get(url)).status_code == 404 + assert (await client.post(url, json=renew)).status_code == 404 + assert "same-version-conflict" not in str(logs) + + +@pytest.mark.parametrize("native_api", ["sessions"], indirect=True) +async def test_parent_restore_requires_fresh_child_attestation_and_active_import( + database, native_api +): + client, _, source, _ = native_api + base = f"/native/sources/{source.source_connection_id}/imports/access" + await client.put(base, json={"snapshot_id": "one", "coverage": "bounded"}) + parent = snapshot( + owner_id="owner", + identity=RecordIdentity(record_type="session", native_id="s"), + version=SessionVersion(revision=1, content_revision=1), + ) + child = snapshot( + owner_id="owner", + identity=RecordIdentity(record_type="message", native_id="m", container_id="s"), + parent=parent.identity, + version=parent.version, + ) + await capture(client, base, parent) + await capture(client, base, child) + parent_url = base + f"/records/{await row_id(database, source, 'session')}/access" + child_url = base + f"/records/{await row_id(database, source, 'message')}/access" + withdrawn = await client.post( + parent_url, + json={ + "action": "withdraw", + "reason": "scope_removed", + "expected_revision": 1, + }, + ) + assert withdrawn.status_code == 200, withdrawn.text + assert not (await client.get(child_url)).json()["available"] + child_renew = { + "expected_parent_epoch": 1, + "action": "renew", + "expected_revision": 1, + "snapshot": child.model_dump(mode="json"), + } + assert (await client.post(child_url, json=child_renew)).status_code == 409 + restored = await client.post( + parent_url, + json={ + "action": "renew", + "expected_revision": 2, + "snapshot": parent.model_dump(mode="json"), + }, + ) + assert restored.status_code == 200, restored.text + assert not (await client.get(child_url)).json()["available"] + # The request prepared before revocation must not cross the parent epoch change. + assert (await client.post(child_url, json=child_renew)).status_code == 409 + parent_new = parent.model_copy( + update={"version": SessionVersion(revision=2, content_revision=1)} + ) + parent_update = await client.post( + parent_url, + json={ + "action": "renew", + "expected_revision": 3, + "snapshot": parent_new.model_dump(mode="json"), + }, + ) + assert parent_update.status_code == 200, parent_update.text + child_new = child.model_copy(update={"version": parent_new.version}) + canonical = CanonicalRecordStore() + imports = NativeImportStore(NativeSourceStore(), canonical) + async with database() as db, UnitOfWork(db): + saved = await imports.active( + db, source.organization_id, source.source_connection_id, "access" + ) + async with database() as db: + with pytest.raises(NativeAdmissionError, match="renewal"): + await NativeIngestionService(NativeIngestionStore(canonical)).ingest( + db, + IngestNativeBatch(fence=saved.fence, observed_at=NOW, snapshots=(child_new,)), + ) + current = (await client.get(child_url)).json() + assert current["revision"] == 1 and current["parent_visibility_epoch"] == 2 + child_renew = child_renew | { + "expected_parent_epoch": current["parent_visibility_epoch"], + "snapshot": child_new.model_dump(mode="json"), + } + refreshed = await client.post(child_url, json=child_renew) + assert refreshed.status_code == 200, refreshed.text + assert refreshed.json()["available"] and refreshed.json()["revision"] == 2 + await client.post(base + "/cancel") + assert (await client.get(child_url)).status_code == 200 + assert ( + await client.post(child_url, json=child_renew | {"expected_revision": 2}) + ).status_code == 409 + async with database() as db: + bound = await db.get(SourceConnection, source.source_connection_id) + bound.is_authenticated = False + await db.commit() + assert not (await client.get(child_url)).json()["available"] diff --git a/backend/airweave/domains/native_ingestion/tests/test_import_api.py b/backend/airweave/domains/native_ingestion/tests/test_import_api.py new file mode 100644 index 0000000000..0ebc3cae24 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_import_api.py @@ -0,0 +1,143 @@ +"""Native HTTP contract with actual PostgreSQL transactions and safe diagnostics.""" + +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from sqlalchemy import func, select + +from airweave.api import deps, middleware +from airweave.api.v1.endpoints.native_imports import router +from airweave.db.session import get_db +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.import_service import NativeImports +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.source_models import EnsureNativeSource +from airweave.domains.native_ingestion.source_service import NativeSources +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.models import Collection, Organization, SyncJob +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +@pytest.fixture(params=["knowledge"]) +async def native_api(database, monkeypatch, request): + org = uuid4() + async with database() as db: + db.add(Organization(id=org, name="Native API")) + metadata = VectorDbDeploymentMetadata( + dense_embedder="test", sparse_embedder="test", embedding_dimensions=3 + ) + db.add(metadata) + await db.flush() + db.add( + Collection( + name="API", + readable_id="native-api", + organization_id=org, + vector_db_deployment_metadata_id=metadata.id, + ) + ) + await db.commit() + sources = NativeSourceStore() + async with database() as db: + source = await NativeSources(sources).ensure( + db, + org, + EnsureNativeSource(owner_id="owner", dataset=request.param, collection="native-api"), + ) + service = NativeImports(NativeImportStore(sources, CanonicalRecordStore())) + ctx = SimpleNamespace(is_api_key_auth=True, organization=SimpleNamespace(id=org)) + app = FastAPI() + app.include_router(router, prefix="/native/sources") + app.exception_handler(RequestValidationError)(middleware.validation_exception_handler) + app.middleware("http")(middleware.log_requests) + logs = [] + monkeypatch.setattr( + middleware, + "logger", + SimpleNamespace(info=logs.append, error=logs.append, warning=logs.append), + ) + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: ctx + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(native_imports=service) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://test" + ) as client: + yield client, ctx, source, logs + + +async def test_import_http_retry_conflict_and_key_validation(database, native_api): + client, _, source, logs = native_api + path = f"/native/sources/{source.source_connection_id}/imports/private-request-key" + body = {"snapshot_id": "private-snapshot-value", "coverage": "bounded"} + first = await client.put(path, json=body) + assert first.status_code == 200, first.text + assert (await client.put(path, json=body)).json() == first.json() + assert (await client.get(path)).json() == first.json() + assert "fence" not in first.json() + conflict = await client.put(path, json={**body, "coverage": "complete"}) + assert conflict.status_code == 409 + assert (await client.put(path + "-other", json=body)).status_code == 409 + assert (await client.get(path + "-missing")).status_code == 404 + assert (await client.put(path + "x" * 129, json=body)).status_code == 422 + assert ( + await client.put(path, json={**body, "coverage": "private-invalid-value"}) + ).status_code == 422 + assert "private-request-key" not in str(logs) + assert "private-snapshot-value" not in str(logs) + assert "private-invalid-value" not in str(logs) + assert "{request_key}" in str(logs) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + + +async def test_import_http_rejects_session_and_foreign_organization(native_api): + client, ctx, source, _ = native_api + path = f"/native/sources/{source.source_connection_id}/imports/request-key" + body = {"snapshot_id": "snapshot", "coverage": "bounded"} + ctx.is_api_key_auth = False + assert (await client.put(path, json=body)).status_code == 403 + assert (await client.get(path)).status_code == 403 + ctx.is_api_key_auth = True + ctx.organization.id = uuid4() + assert (await client.put(path, json=body)).status_code == 404 + assert (await client.get(path)).status_code == 404 + + +async def test_native_analytics_and_exception_logs_keep_only_safe_route(monkeypatch): + from starlette.requests import Request + from starlette.responses import Response + + request = Request( + { + "type": "http", + "method": "PUT", + "scheme": "http", + "headers": [], + "path": "/native/sources/source/imports/private-request-key", + "query_string": b"payload=private-query", + "path_params": {"source_id": "source", "request_key": "private-request-key"}, + "route": SimpleNamespace(path="/native/sources/{source_id}/imports/{request_key}"), + } + ) + captured = [] + monkeypatch.setattr(middleware.analytics, "track_event", lambda *a, **k: captured.append(a)) + monkeypatch.setattr(middleware, "logger", SimpleNamespace(error=captured.append)) + await middleware._track_api_call_async(None, Response(status_code=200), 12.5, request) + + async def failed(_): + raise ValueError("private-payload-content") + + response = await middleware.exception_logging_middleware(request, failed) + assert response.status_code == 500 + assert "private-" not in str(captured) + assert "{request_key}" in str(captured) + assert "private-" not in response.body.decode() diff --git a/backend/airweave/domains/native_ingestion/tests/test_imports.py b/backend/airweave/domains/native_ingestion/tests/test_imports.py new file mode 100644 index 0000000000..d7d73c7b7e --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_imports.py @@ -0,0 +1,215 @@ +"""Real PostgreSQL import request recovery, concurrent creation and rollback.""" + +import asyncio +import hashlib +import json +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.store import CanonicalRecordStore, WriterBusy +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_models import StartNativeImport +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.source_models import EnsureNativeSource +from airweave.domains.native_ingestion.source_service import NativeSources +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +@pytest.fixture(params=["knowledge", "sessions"]) +async def native(database, source, request): + _, fence = source + async with database() as db: + deployment = VectorDbDeploymentMetadata( + dense_embedder="fake", embedding_dimensions=3, sparse_embedder="fake" + ) + db.add(deployment) + await db.flush() + db.add( + Collection( + organization_id=fence.organization_id, + name="Native import", + readable_id="native", + vector_db_deployment_metadata_id=deployment.id, + ) + ) + await db.commit() + async with database() as db: + return await NativeSources(NativeSourceStore()).ensure( + db, + fence.organization_id, + EnsureNativeSource(owner_id="owner", dataset=request.param, collection="native"), + ) + + +async def start(database, native, key="request-one", request=None): + store = NativeImportStore(NativeSourceStore(), CanonicalRecordStore()) + async with database() as db, UnitOfWork(db): + return await store.start( + db, + native.organization_id, + native.source_connection_id, + key, + request or StartNativeImport(snapshot_id="snapshot-one", coverage="bounded"), + ) + + +async def test_concurrent_start_recovers_one_writer_and_rejects_changed_key_body(database, native): + first, duplicate = await asyncio.gather(start(database, native), start(database, native)) + assert first == duplicate + async with database() as db: + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == native.sync_id) + ) + == 1 + ) + sync = await db.get(Sync, native.sync_id) + assert sync.writer_epoch == 1 + with pytest.raises(NativeAdmissionError, match="conflicting intent"): + await start( + database, native, request=StartNativeImport(snapshot_id="other", coverage="complete") + ) + with pytest.raises(WriterBusy): + await start(database, native, key="other-key") + async with database() as db: + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == native.sync_id) + ) + == 1 + ) + + +async def test_terminal_retry_never_reactivates_and_new_request_replaces_partial_cycle( + database, native +): + first = await start(database, native) + async with database() as db: + job = await db.get(SyncJob, first.import_id) + job.status = "cancelled" + await db.commit() + duplicate = await start(database, native) + assert duplicate.status == "cancelled" and duplicate.cycle_id == first.cycle_id + async with database() as db: + assert (await db.get(Sync, native.sync_id)).writer_epoch == 1 + next_import = await start(database, native, key="new-request") + assert next_import.cycle_id != first.cycle_id + async with database() as db: + assert (await db.get(Sync, native.sync_id)).writer_epoch == 2 + assert (await start(database, native)).status == "cancelled" + async with database() as db: + source = await db.get(SourceConnection, native.source_connection_id) + source.is_authenticated = False + await db.commit() + assert (await start(database, native)).status == "cancelled" + with pytest.raises(NativeAdmissionError, match="unavailable"): + await start(database, native, key="after-withdrawal") + async with database() as db: + assert (await db.get(Sync, native.sync_id)).writer_epoch == 2 + + +async def test_cycle_failure_rolls_back_job_and_fence(database, native, monkeypatch): + import airweave.domains.native_ingestion.import_store as module + + async def fail(*args, **kwargs): + raise RuntimeError("cycle publication failed") + + monkeypatch.setattr(module, "begin_cycle", fail) + with pytest.raises(RuntimeError, match="cycle publication"): + await start(database, native) + async with database() as db: + assert (await db.get(Sync, native.sync_id)).writer_epoch == 0 + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == native.sync_id) + ) + == 0 + ) + from fastapi import HTTPException + + async with database() as db, UnitOfWork(db): + with pytest.raises(HTTPException) as error: + await NativeImportStore(NativeSourceStore(), CanonicalRecordStore()).read( + db, uuid4(), native.source_connection_id, "request-one" + ) + assert error.value.status_code == 404 + + +async def test_transcript_coverage_is_sessions_only_and_part_of_retry_identity(database, native): + request = StartNativeImport( + snapshot_id="snapshot-one", coverage="bounded", transcript_coverage="complete" + ) + if native.binding.dataset == "knowledge": + with pytest.raises(NativeAdmissionError, match="requires a sessions source"): + await start(database, native, request=request) + async with database() as db: + assert (await db.get(Sync, native.sync_id)).writer_epoch == 0 + assert ( + await db.scalar( + select(func.count()) + .select_from(SyncJob) + .where(SyncJob.sync_id == native.sync_id) + ) + == 0 + ) + return + + initial = await start(database, native) + assert initial.request.transcript_coverage == "bounded" + with pytest.raises(NativeAdmissionError, match="conflicting intent"): + await start(database, native, request=request) + assert await start(database, native) == initial + + +@pytest.mark.parametrize("native", ["sessions"], indirect=True) +async def test_legacy_import_without_transcript_coverage_resumes_without_changing_plan( + database, native +): + initial = await start(database, native) + async with database() as db: + job = await db.get(SyncJob, initial.import_id) + request = dict(job.sync_metadata["request"]) + request.pop("transcript_coverage") + job.sync_metadata = {**job.sync_metadata, "request": request} + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == native.sync_id)) + cycle = cursor.cursor_data["canonical_cycle"] + plan = dict(cycle["source_plan"]) + plan.pop("transcript_coverage") + fingerprint = hashlib.sha256(json.dumps(plan, sort_keys=True).encode()).hexdigest() + legacy_cycle = { + **cycle, + "source_plan": plan, + "configuration": {**cycle["configuration"], "fingerprint": fingerprint}, + } + cursor.cursor_data = {**cursor.cursor_data, "canonical_cycle": legacy_cycle} + await db.commit() + + assert await start(database, native) == initial + async with database() as db: + cursor = await db.scalar(select(SyncCursor).where(SyncCursor.sync_id == native.sync_id)) + assert cursor.cursor_data["canonical_cycle"] == legacy_cycle + assert (await db.get(Sync, native.sync_id)).writer_epoch == 1 + job = await db.get(SyncJob, initial.import_id) + assert "transcript_coverage" not in job.sync_metadata["request"] + + +async def test_legacy_cancellation_uses_actual_native_sync_identity(database, native, source): + from fastapi import HTTPException + + from airweave.domains.owned_provisioning.guard import require_provider_sync + + async with database() as db: + with pytest.raises(HTTPException) as error: + await require_provider_sync(db, native.sync_id, native.organization_id) + assert error.value.status_code == 409 + _, provider = source + await require_provider_sync(db, provider.sync_id, provider.organization_id) diff --git a/backend/airweave/domains/native_ingestion/tests/test_ingestion.py b/backend/airweave/domains/native_ingestion/tests/test_ingestion.py new file mode 100644 index 0000000000..cdf9ef88a2 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_ingestion.py @@ -0,0 +1,296 @@ +"""Real transactions prove version admission, rollback and retained access state.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CaptureRecord, + RecordIdentity, + RemovedScope, +) +from airweave.domains.entities.canonical.store import ( + CanonicalRecordStore, + CanonicalStoreError, + StaleWriter, + content_is_available, +) +from airweave.domains.entities.canonical.tests.helpers import bind_projection +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.models import ( + IngestNativeBatch, + NativeSnapshot, + RecordVersion, + SessionVersion, +) +from airweave.domains.native_ingestion.service import NativeIngestionService +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.models.entity import Entity +from airweave.models.entity_change import EntityChange +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + +NOW = datetime(2026, 10, 1, tzinfo=timezone.utc) + + +def snapshot(native_id="one", revision=1, **changes): + """Keep original Unicode JSON distinct from index text.""" + fields = { + "owner_id": "owner-one", + "identity": RecordIdentity(record_type="knowledge", native_id=native_id), + "version": RecordVersion(revision=revision), + "original": {"body": "नमस्ते — original", "references": ["people/sam"]}, + } + return NativeSnapshot(**(fields | changes)) + + +async def bind(database, fence, *, dataset="knowledge", provider="almanac"): + binding = await bind_projection(database, fence, provider) + async with database() as db: + source = await db.get(SourceConnection, binding.source_connection_id) + source.config_fields = {"owner_id": "owner-one", "dataset": dataset} + await db.commit() + + +async def ingest(database, fence, *snapshots): + service = NativeIngestionService(NativeIngestionStore(CanonicalRecordStore())) + async with database() as db: + return await service.ingest( + db, IngestNativeBatch(fence=fence, observed_at=NOW, snapshots=snapshots) + ) + + +async def retained(database, fence): + async with database() as db: + return list((await db.scalars(select(Entity).where(Entity.sync_id == fence.sync_id))).all()) + + +async def test_atomic_versions_retry_tombstone_and_stale_resurrection(database, source): + _, fence = source + await bind(database, fence) + first = snapshot(revision=3) + result = await ingest(database, fence, first) + assert result.sequence == 1 + assert (await ingest(database, fence, first)).unchanged == 1 + for invalid in (snapshot(revision=2), snapshot(revision=3, original={"body": "conflict"})): + with pytest.raises(NativeAdmissionError): + await ingest(database, fence, snapshot("second"), invalid) + rows = await retained(database, fence) + assert len(rows) == 1 and rows[0].record_revision == 1 + deleted = snapshot(revision=4, operation="delete", original={}) + result = await ingest(database, fence, deleted) + assert result.sequence == 2 and result.changes[0].kind == "delete" + with pytest.raises(NativeAdmissionError, match="stale"): + await ingest(database, fence, first) + assert (await ingest(database, fence, deleted)).unchanged == 1 + result = await ingest(database, fence, snapshot(revision=5)) + assert result.sequence == 3 and result.changes[0].record.deleted_at is None + + +async def test_session_versions_are_partial_order_and_original_payload_retained(database, source): + _, fence = source + await bind(database, fence, dataset="sessions") + item = snapshot( + identity=RecordIdentity(record_type="session", native_id="session-one"), + version=SessionVersion(revision=3, content_revision=7), + ) + await ingest(database, fence, item) + with pytest.raises(NativeAdmissionError, match="incomparable"): + await ingest( + database, + fence, + item.model_copy(update={"version": SessionVersion(revision=4, content_revision=6)}), + ) + higher = item.model_copy(update={"version": SessionVersion(revision=3, content_revision=8)}) + result = await ingest(database, fence, higher) + assert result.changes[0].record.payload["original"] == item.original + assert result.changes[0].record.payload["representation"] == "snapshot" + + +async def test_visibility_withdrawal_preserves_version_and_retry_does_not_restore(database, source): + canonical, fence = source + await bind(database, fence) + item = snapshot(revision=3) + await ingest(database, fence, item) + async with database() as db: + await canonical.remove_scope( + db, + fence, + RemovedScope(record_type="knowledge", removal_reason="access_revoked", observed_at=NOW), + ) + assert (await ingest(database, fence, item)).unchanged == 1 + row = (await retained(database, fence))[0] + assert row.removal_reason == "access_revoked" + assert row.source_payload["version"]["revision"] == 3 + with pytest.raises(NativeAdmissionError, match="renewal"): + await ingest(database, fence, snapshot(revision=4)) + + +async def test_bound_source_owner_malformed_state_and_superseded_writer(database, source): + canonical, fence = source + with pytest.raises(NativeAdmissionError, match="not bound"): + await ingest(database, fence, snapshot()) + await bind(database, fence) + with pytest.raises(NativeAdmissionError, match="bound native source"): + await ingest(database, fence, snapshot(owner_id="other-owner")) + async with database() as db: + await canonical.capture( + db, + CaptureBatch( + fence=fence, + records=( + CaptureRecord( + identity=snapshot().identity, payload={"original": {}}, observed_at=NOW + ), + ), + ), + ) + with pytest.raises(NativeAdmissionError, match="malformed"): + await ingest(database, fence, snapshot()) + async with database() as db: + await canonical.activate_writer( + db, + fence.organization_id, + fence.sync_id, + fence.job_id, + attempt_id=uuid4(), + attempt_number=2, + ) + with pytest.raises(StaleWriter): + await ingest(database, fence, snapshot("second")) + assert len(await retained(database, fence)) == 1 + + +async def test_provider_binding_cannot_be_used_for_native_ingestion(database, source): + _, fence = source + await bind(database, fence, provider="gmail") + with pytest.raises(NativeAdmissionError, match="not bound"): + await ingest(database, fence, snapshot()) + + +async def test_capture_failure_rolls_back_batch_and_parent_withdrawal_hides_children( + database, source +): + canonical, fence = source + await bind(database, fence, dataset="sessions") + parent = snapshot( + identity=RecordIdentity(record_type="session", native_id="session-one"), + version=SessionVersion(revision=1, content_revision=1), + ) + missing = RecordIdentity(record_type="session", native_id="missing") + late_parent = parent.model_copy(update={"identity": missing}) + child = snapshot( + identity=RecordIdentity( + record_type="message", native_id="message-one", container_id="missing" + ), + parent=missing, + version=parent.version, + original={"role": "user", "content": "original message"}, + ) + with pytest.raises(CanonicalStoreError): + # All pass admission; the child arrives before its parent in capture order. + await ingest(database, fence, parent, child, late_parent) + assert await retained(database, fence) == [] + async with database() as db: + assert ( + await db.scalar(select(Sync.observed_change_sequence).where(Sync.id == fence.sync_id)) + == 0 + ) + assert ( + await db.scalar(select(EntityChange.id).where(EntityChange.sync_id == fence.sync_id)) + is None + ) + child = child.model_copy( + update={ + "parent": parent.identity, + "identity": RecordIdentity( + record_type="message", native_id="message-one", container_id="session-one" + ), + } + ) + result = await ingest(database, fence, parent, child) + assert result.sequence == 2 and [change.sequence for change in result.changes] == [1, 2] + async with database() as db: + await canonical.remove_scope( + db, + fence, + RemovedScope(record_type="session", removal_reason="access_revoked", observed_at=NOW), + ) + assert (await ingest(database, fence, child)).unchanged == 1 + async with database() as db: + visible = await db.scalar( + select(content_is_available()).where( + Entity.sync_id == fence.sync_id, Entity.native_id == "message-one" + ) + ) + assert visible is False + higher = child.model_copy(update={"version": SessionVersion(revision=1, content_revision=2)}) + with pytest.raises(CanonicalStoreError): + await ingest(database, fence, higher) + rows = await retained(database, fence) + message = next(row for row in rows if row.native_id == "message-one") + assert message.record_revision == 1 + + +async def test_late_unseen_message_cannot_join_newer_session(database, source): + _, fence = source + await bind(database, fence, dataset="sessions") + parent = snapshot( + identity=RecordIdentity(record_type="session", native_id="session-one"), + version=SessionVersion(revision=3, content_revision=10), + ) + await ingest(database, fence, parent) + child = snapshot( + identity=RecordIdentity( + record_type="message", native_id="late", container_id="session-one" + ), + parent=parent.identity, + version=SessionVersion(revision=2, content_revision=8), + ) + with pytest.raises(NativeAdmissionError, match="attested native session"): + await ingest(database, fence, child) + assert len(await retained(database, fence)) == 1 + current = child.model_copy(update={"version": parent.version}) + assert (await ingest(database, fence, current)).sequence == 2 + advanced = parent.model_copy( + update={"version": SessionVersion(revision=3, content_revision=11)} + ) + assert (await ingest(database, fence, advanced)).sequence == 3 + assert (await ingest(database, fence, current)).unchanged == 1 + unseen = current.model_copy( + update={ + "identity": RecordIdentity( + record_type="message", native_id="unseen", container_id="session-one" + ) + } + ) + with pytest.raises(NativeAdmissionError, match="attested native session"): + await ingest(database, fence, unseen) + stale_delete = current.model_copy(update={"operation": "delete", "original": {}}) + with pytest.raises(NativeAdmissionError, match="conflicting content"): + await ingest(database, fence, stale_delete) + # A later child version can still be stale relative to the parent. + newest = advanced.model_copy( + update={"version": SessionVersion(revision=3, content_revision=12)} + ) + await ingest(database, fence, newest) + stale_delete = stale_delete.model_copy(update={"version": advanced.version}) + with pytest.raises(NativeAdmissionError, match="attested native session"): + await ingest(database, fence, stale_delete) + current_delete = stale_delete.model_copy(update={"version": newest.version}) + result = await ingest(database, fence, current_delete) + assert result.changes[0].kind == "delete" + # Parent tombstones permit matching child tombstones, but never child upserts. + parent_delete = newest.model_copy( + update={ + "version": SessionVersion(revision=3, content_revision=13), + "operation": "delete", + "original": {}, + } + ) + child_delete = current_delete.model_copy(update={"version": parent_delete.version}) + result = await ingest(database, fence, parent_delete, child_delete) + assert [change.kind for change in result.changes] == ["delete", "delete"] diff --git a/backend/airweave/domains/native_ingestion/tests/test_maintenance_ownership.py b/backend/airweave/domains/native_ingestion/tests/test_maintenance_ownership.py new file mode 100644 index 0000000000..a53b13b981 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_maintenance_ownership.py @@ -0,0 +1,27 @@ +"""Provider timeout maintenance cannot terminate resumable native imports.""" + +from datetime import timedelta + +import pytest +from sqlalchemy import update + +from airweave.core.datetime_utils import utc_now_naive +from airweave.crud.crud_sync_job import sync_job +from airweave.domains.native_ingestion.tests.test_imports import native, start # noqa: F401 +from airweave.models.sync_job import SyncJob + + +@pytest.mark.parametrize("native", ["knowledge"], indirect=True) +async def test_stuck_provider_query_excludes_native_import(database, source, native): # noqa: F811 + imported = await start(database, native) + _, provider = source + cutoff = utc_now_naive() - timedelta(minutes=15) + async with database() as db: + await db.execute(update(SyncJob).values(started_at=cutoff - timedelta(minutes=1))) + await db.commit() + jobs = await sync_job.get_stuck_jobs_by_status( + db, status=["running"], started_before=cutoff + ) + assert {job.id for job in jobs} == {provider.job_id} + # Exclusion does not terminate or replace the durable native import. + assert await start(database, native) == imported diff --git a/backend/airweave/domains/native_ingestion/tests/test_observed_membership.py b/backend/airweave/domains/native_ingestion/tests/test_observed_membership.py new file mode 100644 index 0000000000..ed1f8833d9 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_observed_membership.py @@ -0,0 +1,177 @@ +"""Bounded native imports own only freshly observed parent/ancestor membership.""" + +import hashlib +import json + +import pytest + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CompleteCycle, + CycleConfiguration, +) +from airweave.domains.entities.canonical.cycle_store import ( + CycleConflict, + begin_cycle, + complete_cycle, + cursor_row, + cycle_state, + next_scope_work, +) +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.models import IngestNativePage, SessionVersion +from airweave.domains.native_ingestion.service import NativeIngestionService +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.domains.native_ingestion.tests.test_ingestion import ( + NOW, + bind, + ingest, + retained, + snapshot, +) + + +async def start_cycle(database, canonical, fence, *, expected=None): + async with database() as db, UnitOfWork(db): + await canonical._fenced_sync(db, fence) + return await begin_cycle( + db, + BeginCycle( + fence=fence, + expected=expected, + configuration=CycleConfiguration( + fingerprint="a" * 64, + parents={"session": (None,), "message": ("session",)}, + completion_policies={"session": "discovery_only", "message": "discovery_only"}, + membership="observed", + ), + ), + ) + + +async def finish_scope(database, canonical, fence, cycle, scope, items=(), *, epoch=None): + scans = CanonicalScanStore(canonical) + async with database() as db, UnitOfWork(db): + previous = await scans.read(db, fence, scope) + state = await scans.begin( + db, + BeginScan( + fence=fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + fingerprint=cycle.configuration.fingerprint, + expected=previous.version if previous else None, + expected_parent_epoch=epoch, + ), + ) + async with database() as db: + result = await NativeIngestionService(NativeIngestionStore(canonical)).page( + db, + IngestNativePage( + fence=fence, + observed_at=NOW, + snapshots=items, + scope=scope, + cycle_id=cycle.version.cycle_id, + expected=state.version, + final=True, + continuation=ScanContinuation(), + ), + ) + async with database() as db, UnitOfWork(db): + await scans.reconcile( + db, + ReconcileScan( + fence=fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + expected=result.state.version, + observed_at=NOW, + ), + ) + return result + + +async def finish_cycle(database, canonical, fence): + async with database() as db, UnitOfWork(db): + sync = await canonical._fenced_sync(db, fence) + cycle = cycle_state(await cursor_row(db, fence)) + return await complete_cycle(db, sync, CompleteCycle(fence=fence, expected=cycle.version)) + + +async def test_only_observed_unchanged_parent_requires_transcript_and_empty_import_keeps_all( + database, source +): + _, fence = source + canonical = CanonicalRecordStore() + await bind(database, fence, dataset="sessions") + first, second = ( + snapshot( + name, + identity=RecordIdentity(record_type="session", native_id=name), + version=SessionVersion(revision=1, content_revision=1), + ) + for name in ("first", "second") + ) + await ingest(database, fence, first, second) + cycle = await start_cycle(database, canonical, fence) + root = CompletedScope(record_type="session") + result = await finish_scope(database, canonical, fence, cycle, root, (first,)) + assert result.capture.unchanged == 1 and result.capture.changes == () + with pytest.raises(CycleConflict, match="incomplete child"): + await finish_cycle(database, canonical, fence) + async with database() as db, UnitOfWork(db): + work = await next_scope_work(db, fence, cycle.version.cycle_id) + assert work.parent.identity == first.identity + with pytest.raises(CycleConflict, match="membership"): + await finish_scope( + database, + canonical, + fence, + cycle, + CompletedScope(record_type="message", container_id="second", parent=second.identity), + epoch=1, + ) + await finish_scope( + database, + canonical, + fence, + cycle, + CompletedScope(record_type="message", container_id="first", parent=first.identity), + epoch=work.parent_visibility_epoch, + ) + completed = await finish_cycle(database, canonical, fence) + assert completed.last_full_capture.discovery == "incomplete" + empty = await start_cycle(database, canonical, fence, expected=completed.version) + await finish_scope(database, canonical, fence, empty, root) + async with database() as db, UnitOfWork(db): + assert await next_scope_work(db, fence, empty.version.cycle_id) is None + completed_empty = await finish_cycle(database, canonical, fence) + assert completed_empty.last_full_capture.discovery == "incomplete" + rows = await retained(database, fence) + assert len(rows) == 2 and all(row.deleted_at is None for row in rows) + + +def test_default_configuration_digest_stays_compatible(): + configuration = CycleConfiguration(fingerprint="a" * 64, parents={"session": (None,)}) + legacy = { + "fingerprint": "a" * 64, + "parents": {"session": [None]}, + "completion_policies": {"session": "exhaustive"}, + } + assert ( + configuration.digest() + == hashlib.sha256(json.dumps(legacy, sort_keys=True).encode()).hexdigest() + ) + assert ( + configuration.model_copy(update={"membership": "observed"}).digest() + != configuration.digest() + ) diff --git a/backend/airweave/domains/native_ingestion/tests/test_page_receipts.py b/backend/airweave/domains/native_ingestion/tests/test_page_receipts.py new file mode 100644 index 0000000000..d2c9e1bab3 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_page_receipts.py @@ -0,0 +1,147 @@ +"""Exact lost-response recovery uses the same real transaction as native page writes.""" + + +# Imported pytest fixture names are resolved through function parameters. +# ruff: noqa: F811 + +import asyncio +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.cycle_store import cursor_row, cycle_state +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import BeginScan +from airweave.domains.entities.canonical.scan_store import CanonicalScanStore, ScanConflict +from airweave.domains.entities.canonical.store import CanonicalRecordStore, StaleWriter +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.models import SessionVersion +from airweave.domains.native_ingestion.page_models import CommitNativePage +from airweave.domains.native_ingestion.page_store import NativePageStore +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.domains.native_ingestion.tests.test_imports import native, start # noqa: F401 +from airweave.domains.native_ingestion.tests.test_ingestion import snapshot +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob + + +@pytest.fixture +async def scope(database, native): + await start(database, native) + canonical = CanonicalRecordStore() + imports = NativeImportStore(NativeSourceStore(), canonical) + async with database() as db, UnitOfWork(db): + saved = await imports.active( + db, native.organization_id, native.source_connection_id, "request-one" + ) + cycle = cycle_state(await cursor_row(db, saved.fence)) + kind = "knowledge" if native.binding.dataset == "knowledge" else "session" + state = await CanonicalScanStore(canonical).begin( + db, + BeginScan( + fence=saved.fence, + scope=CompletedScope(record_type=kind), + cycle_id=saved.cycle_id, + fingerprint=cycle.configuration.fingerprint, + ), + ) + return state + + +def page_request(native, state): + item = snapshot(owner_id=native.binding.owner_id) + if native.binding.dataset == "sessions": + item = item.model_copy( + update={ + "identity": RecordIdentity(record_type="session", native_id="one"), + "version": SessionVersion(revision=1, content_revision=1), + } + ) + return CommitNativePage( + page_id=uuid4(), + scope=state.scope, + expected=state.version, + snapshots=(item,), + cursor={"next": "two"}, + ) + + +async def commit(database, native, request): + canonical = CanonicalRecordStore() + pages = NativePageStore( + NativeImportStore(NativeSourceStore(), canonical), NativeIngestionStore(canonical) + ) + async with database() as db, UnitOfWork(db): + return await pages.commit( + db, native.organization_id, native.source_connection_id, "request-one", request + ) + + +async def test_concurrent_page_retry_conflict_and_superseded_receipt(database, native, scope): + request = page_request(native, scope) + first, duplicate = await asyncio.gather( + commit(database, native, request), commit(database, native, request) + ) + assert first == duplicate and first.changed == 1 and first.sequence == 1 + async with database() as db: + assert ( + await db.scalar( + select(func.count()).select_from(Entity).where(Entity.sync_id == native.sync_id) + ) + == 1 + ) + with pytest.raises(ScanConflict, match="retry conflicts"): + await commit(database, native, request.model_copy(update={"final": True})) + last = request.model_copy( + update={ + "page_id": uuid4(), + "expected": first.version, + "snapshots": (), + "final": True, + } + ) + final = await commit(database, native, last) + assert final.phase == "reconciling" + assert await commit(database, native, last) == final + with pytest.raises(ScanConflict, match="Page changed"): + await commit(database, native, request) + + +async def test_cancelled_import_cannot_replay_a_page_receipt(database, native, scope): + request = page_request(native, scope) + await commit(database, native, request) + imported = await start(database, native) + async with database() as db: + job = await db.get(SyncJob, imported.import_id) + job.status = "cancelled" + await db.commit() + with pytest.raises(StaleWriter): + await commit(database, native, request) + + +async def test_receipt_failure_rolls_back_capture_and_cursor(database, native, scope, monkeypatch): + import airweave.domains.native_ingestion.page_store as module + + def fail(**kwargs): + raise RuntimeError("receipt persistence failed") + + monkeypatch.setattr(module, "NativePageReceipt", fail) + with pytest.raises(RuntimeError, match="receipt persistence"): + await commit(database, native, page_request(native, scope)) + canonical = CanonicalRecordStore() + imports = NativeImportStore(NativeSourceStore(), canonical) + async with database() as db, UnitOfWork(db): + saved = await imports.active( + db, native.organization_id, native.source_connection_id, "request-one" + ) + current = await CanonicalScanStore(canonical).read(db, saved.fence, scope.scope) + assert current == scope + assert ( + await db.scalar( + select(func.count()).select_from(Entity).where(Entity.sync_id == native.sync_id) + ) + == 0 + ) diff --git a/backend/airweave/domains/native_ingestion/tests/test_pages.py b/backend/airweave/domains/native_ingestion/tests/test_pages.py new file mode 100644 index 0000000000..8384285eba --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_pages.py @@ -0,0 +1,230 @@ +"""Native admission, capture and sweep continuation share one real transaction.""" + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import BeginCycle, CycleConfiguration +from airweave.domains.entities.canonical.requests import ( + CompletedScope, + RecordIdentity, + RemovedScope, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + ReconcileScan, + ScanContinuation, +) +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.models import IngestNativePage, SessionVersion +from airweave.domains.native_ingestion.service import NativeIngestionService +from airweave.domains.native_ingestion.store import NativeIngestionStore +from airweave.domains.native_ingestion.tests.test_ingestion import ( + NOW, + bind, + ingest, + retained, + snapshot, +) +from airweave.models.entity import Entity + + +async def begin(database, canonical, fence, *, sessions=False): + async with database() as db: + return await canonical.begin_cycle( + db, + BeginCycle( + fence=fence, + configuration=CycleConfiguration( + fingerprint="a" * 64, + parents={"session": (None,), "message": ("session",)} + if sessions + else {"knowledge": (None,)}, + ), + ), + ) + + +async def scan(database, canonical, fence, cycle, scope, epoch=None): + async with database() as db: + return await canonical.begin_scan( + db, + BeginScan( + fence=fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + fingerprint="a" * 64, + expected_parent_epoch=epoch, + ), + ) + + +async def page(database, fence, state, *items, final=False): + service = NativeIngestionService(NativeIngestionStore(CanonicalRecordStore())) + async with database() as db: + return await service.page( + db, + IngestNativePage( + fence=fence, + observed_at=NOW, + snapshots=items, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + continuation=ScanContinuation(value={"next": "two"}), + final=final, + ), + ) + + +async def test_unchanged_new_sweep_survives_reconciliation_without_new_revision(database, source): + canonical, fence = source + await bind(database, fence) + original = snapshot() + await ingest(database, fence, original) + cycle = await begin(database, canonical, fence) + state = await scan(database, canonical, fence, cycle, CompletedScope(record_type="knowledge")) + result = await page(database, fence, state, original, final=True) + assert result.capture.unchanged == 1 and result.capture.changes == () + async with database() as db: + reconciled = await canonical.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=result.state.version, + observed_at=NOW, + ), + ) + row = (await retained(database, fence))[0] + assert row.last_seen_run_id == state.version.sweep_id + assert row.record_revision == 1 and row.deleted_at is None + assert reconciled.capture.changes == () + + +async def test_withdrawn_or_tombstoned_snapshot_cannot_be_seen_again(database, source): + canonical, fence = source + await bind(database, fence) + item = snapshot() + await ingest(database, fence, item) + cycle = await begin(database, canonical, fence) + state = await scan(database, canonical, fence, cycle, CompletedScope(record_type="knowledge")) + async with database() as db: + await canonical.remove_scope( + db, + fence, + RemovedScope(record_type="knowledge", removal_reason="access_revoked", observed_at=NOW), + ) + with pytest.raises(NativeAdmissionError, match="active original sightings"): + await page(database, fence, state, item) + with pytest.raises(NativeAdmissionError, match="not tombstones"): + await page(database, fence, state, item.model_copy(update={"operation": "delete"})) + async with database() as db: + current = await canonical.read_scan(db, fence, state.scope) + assert current == state + + +async def test_stale_unchanged_child_does_not_attest_membership_of_newer_parent(database, source): + canonical, fence = source + await bind(database, fence, dataset="sessions") + parent = snapshot( + identity=RecordIdentity(record_type="session", native_id="s"), + version=SessionVersion(revision=1, content_revision=1), + ) + child = snapshot( + identity=RecordIdentity(record_type="message", native_id="m", container_id="s"), + parent=parent.identity, + version=parent.version, + ) + await ingest(database, fence, parent, child) + newer = parent.model_copy(update={"version": SessionVersion(revision=1, content_revision=2)}) + await ingest(database, fence, newer) + cycle = await begin(database, canonical, fence, sessions=True) + root = await scan(database, canonical, fence, cycle, CompletedScope(record_type="session")) + root_result = await page(database, fence, root, newer, final=True) + async with database() as db: + await canonical.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=root.scope, + cycle_id=root.cycle_id, + expected=root_result.state.version, + observed_at=NOW, + ), + ) + async with database() as db: + epoch = await db.scalar( + select(Entity.visibility_epoch).where( + Entity.sync_id == fence.sync_id, Entity.native_id == "s" + ) + ) + state = await scan( + database, + canonical, + fence, + cycle, + CompletedScope(record_type="message", container_id="s", parent=parent.identity), + epoch, + ) + with pytest.raises(NativeAdmissionError, match="attested native session"): + await page(database, fence, state, child) + assert ( + await ingest(database, fence, child) + ).unchanged == 1 # Ordinary retry is not a new sighting. + updated = child.model_copy(update={"version": newer.version}) + result = await page(database, fence, state, updated) + assert result.capture.changes[0].record.revision == 2 + + +async def test_failure_after_capture_rolls_back_records_sightings_and_cursor( + database, source, monkeypatch +): + import airweave.domains.entities.canonical.scan_store as scan_module + + canonical, fence = source + await bind(database, fence) + original = snapshot() + await ingest(database, fence, original) + before = (await retained(database, fence))[0].last_seen_run_id + cycle = await begin(database, canonical, fence) + state = await scan(database, canonical, fence, cycle, CompletedScope(record_type="knowledge")) + + def fail(*args, **kwargs): + raise RuntimeError("synthetic failure after page writes") + + monkeypatch.setattr(scan_module, "publish_scope", fail) + with pytest.raises(RuntimeError, match="after page writes"): + await page(database, fence, state, original, snapshot("new"), final=True) + rows = await retained(database, fence) + assert len(rows) == 1 and rows[0].last_seen_run_id == before + async with database() as db: + assert await canonical.read_scan(db, fence, state.scope) == state + + +async def test_empty_final_page_is_explicit_completion_not_implicit_absence(database, source): + canonical, fence = source + await bind(database, fence) + await ingest(database, fence, snapshot()) + cycle = await begin(database, canonical, fence) + state = await scan(database, canonical, fence, cycle, CompletedScope(record_type="knowledge")) + partial = await page(database, fence, state) + assert partial.state.phase == "collecting" + assert (await retained(database, fence))[0].deleted_at is None + final = await page(database, fence, partial.state, final=True) + assert final.state.phase == "reconciling" + assert (await retained(database, fence))[0].deleted_at is None + async with database() as db: + await canonical.reconcile_scan( + db, + ReconcileScan( + fence=fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=final.state.version, + observed_at=NOW, + ), + ) + row = (await retained(database, fence))[0] + assert row.removal_reason == "absent" and row.source_payload["version"]["revision"] == 1 diff --git a/backend/airweave/domains/native_ingestion/tests/test_projection.py b/backend/airweave/domains/native_ingestion/tests/test_projection.py new file mode 100644 index 0000000000..36e6849547 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_projection.py @@ -0,0 +1,349 @@ +"""Native model-shaped fixtures; real SQL publication, synthetic embedding/destination.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.projection_policy import excluded_from_search +from airweave.domains.entities.canonical.projection_store import ( + CanonicalProjectionStore, + current_extraction, +) +from airweave.domains.entities.canonical.query import CanonicalQueryService +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.entities.canonical.tests.test_extraction_coverage import ( + destination, + projector, +) +from airweave.domains.entities.canonical.text_query import CanonicalTextReader +from airweave.domains.native_ingestion.models import RecordVersion, SessionVersion +from airweave.domains.native_ingestion.tests.test_ingestion import NOW, bind, ingest, snapshot + + +def knowledge(**changes): + original = { + "id": "one", + "type": "page", + "revision": 1, + "title": "Fundraising", + "description": "A useful original page", + "path": "notes/fundraising", + "body": "नमस्ते 👋\n# Original body", + "created_at": NOW.isoformat(), + "updated_at": NOW.isoformat(), + "archived_at": None, + "citations": {}, + "field_citations": {}, + "aliases": [], + "image": None, + "about": [], + "user_notes": "A note", + } + return snapshot(original=original | changes, source_created_at=NOW, source_updated_at=NOW) + + +def session(): + return snapshot( + identity=RecordIdentity(record_type="session", native_id="s1"), + version=SessionVersion(revision=1, content_revision=1), + source_created_at=NOW, + source_updated_at=NOW, + original={ + "id": "s1", + "title": "Session title", + "title_source": "user", + "description": "Original session description", + "source": "chat", + "metadata": {}, + "archived": True, + "pinned": False, + "revision": 1, + "content_revision": 1, + "replay_epoch": 4, + "message_count": 1, + "earlier_message_count": 0, + "project": None, + "effective_project": None, + "work": None, + "created_at": NOW.isoformat(), + "updated_at": NOW.isoformat(), + }, + ) + + +def message(content, **changes): + parent = session() + original = { + "id": "m1", + "input_id": None, + "ordinal": 0, + "import_provenance": None, + "payload": { + "role": "user", + "content": content, + "api_content": "DO NOT INDEX", + "reasoning_content": "PRIVATE REPLAY TEXT", + }, + "created_at": NOW.isoformat(), + } + return snapshot( + identity=RecordIdentity(record_type="message", native_id="m1", container_id="s1"), + parent=parent.identity, + version=parent.version, + original=original | changes, + source_created_at=NOW, + ) + + +async def test_knowledge_identity_rename_archive_and_malformed_original(database, source): + _, fence = source + await bind(database, fence) + result = await ingest(database, fence, knowledge()) + record = result.changes[0].record + async with map_record(record, "almanac", AsyncMock()) as mapped: + assert mapped.parts[0].entity.entity_id == "one" + assert mapped.parts[0].native_body.text == knowledge().original["body"] + renamed = knowledge(path="notes/renamed", revision=2).model_copy( + update={"version": RecordVersion(revision=2)} + ) + record = (await ingest(database, fence, renamed)).changes[0].record + async with map_record(record, "almanac", AsyncMock()) as mapped: + assert mapped.parts[0].entity.entity_id == "one" + assert mapped.parts[0].entity.path == "notes/renamed" + bad = record.model_copy( + update={ + "payload": renamed.model_copy( + update={"original": renamed.original | {"id": "wrong"}} + ).model_dump(mode="json") + } + ) + with pytest.raises(ValueError, match="ID differs"): + async with map_record(bad, "almanac", AsyncMock()): + pass + archived = renamed.model_copy( + update={"original": renamed.original | {"archived_at": NOW.isoformat()}} + ) + assert excluded_from_search( + record.model_copy(update={"payload": archived.model_dump(mode="json")}), "almanac" + ) + + +async def test_mixed_message_publishes_partial_coverage_and_exact_native_text( + database, source, tmp_path +): + _, fence = source + await bind(database, fence, dataset="sessions") + text = "नमस्ते 👋\n# Content\nOriginal user words" + item = message( + [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": "https://example.org/image"}}, + ] + ) + result = await ingest(database, fence, session(), item) + assert not excluded_from_search( + result.changes[0].record, "almanac" + ) # Archived sessions included today. + storage = FilesystemBackend(tmp_path) + projection = projector(database, storage) + indexed = [] + + async def fixed_chunks(entities, context, runtime): + indexed.extend(entity.textual_representation for entity in entities) + return projection._processor._multiply_entities( + entities, [[{"text": entity.textual_representation}] for entity in entities], context + ) + + projection._processor._chunk_entities = fixed_chunks + async with database() as db: + pending = await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id) + work = next(work for work in pending if work.record.identity.record_type == "message") + async with map_record(work.record, "almanac", storage) as mapped: + assert mapped.parts[0].entity.message_id == "m1" + assert mapped.parts[0].entity.session_id == "s1" + assert mapped.parts[0].entity.block_index == 0 + assert mapped.parts[0].native_body.text == text + assert mapped.parts[1].omission == "unsupported_format" + assert await projection.project_one( + work, "almanac", destination(work.binding.collection_id), MagicMock() + ) + assert indexed[0].endswith(text) + assert "DO NOT INDEX" not in indexed[0] and "PRIVATE REPLAY TEXT" not in indexed[0] + async with database() as db: + coverage = await current_extraction( + db, fence.organization_id, fence.sync_id, work.record.id, work.record.revision + ) + assert coverage.status == "partial" + assert [part.outcome for part in coverage.parts] == ["indexed", "unsupported"] + reader = CanonicalTextReader( + CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-key"), storage + ) + async with database() as db: + representations = await reader.list( + db, fence.organization_id, fence.sync_id, work.record.id, work.record.revision + ) + ref = representations.representations[0] + native = await reader.read( + db, + fence.organization_id, + fence.sync_id, + work.record.id, + work.record.revision, + ref.generation, + ref.id, + ) + assert ref.kind == "native_text" and native.text == text + + +async def test_inactive_import_and_invalid_text_are_not_successful_empty_indexing(database, source): + _, fence = source + await bind(database, fence, dataset="sessions") + inactive = message( + "old", import_provenance={"legacy_row_id": 1, "active": False, "compacted": True} + ) + result = await ingest(database, fence, session(), inactive) + assert excluded_from_search(result.changes[1].record, "almanac") + malformed = message([{"type": "text", "text": {"unexpected": "object"}}]) + record = result.changes[1].record.model_copy( + update={"payload": malformed.model_dump(mode="json")} + ) + with pytest.raises(ValueError): + async with map_record(record, "almanac", AsyncMock()): + pass + + +async def test_person_fields_enter_index_without_rewriting_native_body(database, source, tmp_path): + _, fence = source + await bind(database, fence) + item = knowledge( + type="person", + emails=["sam@example.org"], + roles=[ + { + "organisation": {"type": "organisation", "id": "opaque-id"}, + "title": "Research engineer", + "start": "2024", + "end": None, + } + ], + links=[{"label": "Profile", "url": "https://example.org/sam", "handle": "samdev"}], + metadata={"secret": "UNSELECTED_METADATA"}, + ) + captured = await ingest(database, fence, item) + storage = FilesystemBackend(tmp_path) + projection = projector(database, storage) + indexed = [] + + async def fixed_chunks(entities, context, runtime): + indexed.extend(entity.textual_representation for entity in entities) + return projection._processor._multiply_entities( + entities, [[{"text": entity.textual_representation}] for entity in entities], context + ) + + projection._processor._chunk_entities = fixed_chunks + async with database() as db: + work = (await CanonicalProjectionStore().pending(db, fence.organization_id, fence.sync_id))[ + 0 + ] + assert await projection.project_one( + work, "almanac", destination(work.binding.collection_id), MagicMock() + ) + assert "sam@example.org" in indexed[0] and "Research engineer" in indexed[0] + assert "samdev" in indexed[0] and "2024" in indexed[0] + assert "UNSELECTED_METADATA" not in indexed[0] and "opaque-id" not in indexed[0] + assert captured.changes[0].record.payload["original"] == item.original + reader = CanonicalTextReader( + CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "test-key"), storage + ) + async with database() as db: + representations = await reader.list( + db, fence.organization_id, fence.sync_id, work.record.id, 1 + ) + ref = representations.representations[0] + content = await reader.read( + db, + fence.organization_id, + fence.sync_id, + work.record.id, + 1, + ref.generation, + ref.id, + view="content", + ) + prepared = await reader.read( + db, + fence.organization_id, + fence.sync_id, + work.record.id, + 1, + ref.generation, + ref.id, + view="index", + ) + assert content.text == item.original["body"] + assert "sam@example.org" in prepared.text + + +@pytest.mark.parametrize( + "kind,fields,expected", + [ + ( + "organisation", + {"domains": ["example.org"], "industry": ["Robotics"], "founded": "2020"}, + "Robotics", + ), + ( + "place", + {"place_kind": "Office", "address": {"locality": "मुंबई", "country": "India"}}, + "मुंबई", + ), + ("creative_work", {"work_kind": "Book", "published_on": "2025-01-01"}, "2025-01-01"), + ], +) +def test_selected_knowledge_fields_preserve_native_text(kind, fields, expected): + from airweave.domains.native_ingestion.knowledge_fields import knowledge_details + + text = knowledge_details(kind, fields | {"metadata": {"private": "UNSELECTED"}}) + assert expected in text and "UNSELECTED" not in text + + +def test_invalid_selected_field_fails_instead_of_stringifying_json(): + from pydantic import ValidationError + + from airweave.domains.native_ingestion.knowledge_fields import knowledge_details + + with pytest.raises(ValidationError): + knowledge_details("person", {"emails": [{"unexpected": "value"}]}) + + +def test_event_and_relationship_values_are_searchable_without_resolving_references(): + from airweave.domains.native_ingestion.knowledge_fields import knowledge_details + + assert "2026-10-01" in knowledge_details( + "event", + { + "schedule": { + "kind": "all_day", + "start_on": "2026-10-01", + "end_on_exclusive": "2026-10-02", + } + }, + ) + details = knowledge_details( + "person", + { + "related_people": [ + {"person": {"type": "person", "id": "hidden-id"}, "relationship": "Co-founder"} + ], + "education": [{"institution": {"type": "organisation", "id": "hidden-school"}}], + }, + ) + assert details == "related people: relationship: Co-founder" + coordinates = knowledge_details( + "place", {"coordinates": {"latitude": 19.07, "longitude": 72.87}} + ) + assert "19.07" in coordinates and "72.87" in coordinates diff --git a/backend/airweave/domains/native_ingestion/tests/test_publish_cli.py b/backend/airweave/domains/native_ingestion/tests/test_publish_cli.py new file mode 100644 index 0000000000..34b6c14c8d --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_publish_cli.py @@ -0,0 +1,171 @@ +"""CLI validation and outcome claims, without duplicating publisher lifecycle tests.""" + +import json +from uuid import UUID + +import httpx +import pytest +from evaluation import native_import_cli as cli + +from airweave.domains.native_ingestion.tests.test_ingestion import snapshot + + +@pytest.fixture +def invocation(tmp_path, monkeypatch): + monkeypatch.setenv("AIRWEAVE_API_KEY", "private-test-key") + path = tmp_path / "staged.json" + path.write_text(json.dumps([snapshot().model_dump(mode="json")])) + return path, [ + "--url", + "http://127.0.0.1:18086/api/v1/", + "--input", + str(path), + "--owner", + "owner-one", + "--dataset", + "knowledge", + "--collection", + "native-test", + "--request-key", + "repeat-this", + ] + + +def transport(monkeypatch, handler): + client = httpx.AsyncClient + + def configured(**kw): + assert kw["follow_redirects"] is False and kw["trust_env"] is False + return client(transport=httpx.MockTransport(handler), **kw) + + monkeypatch.setattr(cli.httpx, "AsyncClient", configured) + + +@pytest.mark.parametrize("invalid", ["json", "oversize", "owner", "credentials"]) +def test_invalid_input_makes_no_http(invocation, monkeypatch, capsys, invalid): + path, args = invocation + calls = [] + + def no_http(request): + calls.append(request) + raise AssertionError("Invalid input reached HTTP") + + transport(monkeypatch, no_http) + if invalid == "json": + path.write_text('{"secret-content":') + elif invalid == "oversize": + monkeypatch.setattr(cli, "MAX_INPUT_BYTES", 1) + elif invalid == "owner": + args[5] = "wrong-owner" + else: + monkeypatch.delenv("AIRWEAVE_API_KEY") + assert cli.main(args) in (2, 4) + assert not calls + output = capsys.readouterr() + assert not output.out + assert "secret-content" not in output.err and "private-test-key" not in output.err + + +def test_lost_response_is_unknown_not_success(invocation, monkeypatch, capsys): + _, args = invocation + calls = [] + + def lose(request): + calls.append(request) + raise httpx.ReadTimeout("private-test-key and private body", request=request) + + transport(monkeypatch, lose) + assert cli.main(args) == 3 + assert len(calls) == 1 + output = capsys.readouterr() + assert not output.out + assert "outcome unknown" in output.err and "private" not in output.err + + +def test_completed_retry_outputs_capture_summary(invocation, monkeypatch, capsys): + _, args = invocation + requests = [] + identifier = str(UUID(int=1)) + + def destination(request): + requests.append(request) + assert request.headers["X-API-Key"] == "private-test-key" + if request.url.path.endswith("/native/sources"): + return httpx.Response( + 200, + json={ + "source_connection_id": identifier, + "sync_id": identifier, + "organization_id": identifier, + "binding": {"owner_id": "owner-one", "dataset": "knowledge"}, + "collection": "native-test", + "available": True, + }, + ) + assert request.url.path.endswith("/imports/repeat-this") + return httpx.Response( + 200, + json={ + "source_id": identifier, + "import_id": identifier, + "cycle_id": identifier, + "request_key": "repeat-this", + "request": json.loads(request.content), + "status": "completed", + "summary": { + "outcome": "completed", + "coverage": "bounded", + "finished_at": "2026-10-01T00:00:00Z", + "sequence": 1, + "completed_scopes": 1, + "capture_complete": True, + "indexing": "not_verified", + }, + }, + ) + + transport(monkeypatch, destination) + assert cli.main(args) == 0 + assert len(requests) == 2 + output = capsys.readouterr() + assert not output.err + summary = json.loads(output.out) + assert summary["capture_complete"] and summary["indexing"] == "not_verified" + assert "private-test-key" not in output.out + + +@pytest.mark.parametrize( + "url", + [ + "https://user:secret@example.com/api/", + "https://example.com/api/?", + "https://example.com/api/#", + "http://localhost.example.com/api/", + "file:///tmp/api/", + ], +) +def test_unsafe_url_rejected(invocation, url, capsys): + _, args = invocation + args[1] = url + assert cli.main(args) == 2 + assert url not in capsys.readouterr().err + + +def test_help_needs_no_server_settings(tmp_path): + import os + import subprocess + import sys + from pathlib import Path + + backend = Path(__file__).resolve().parents[4] + result = subprocess.run( + [sys.executable, "-m", "evaluation.native_import_cli", "--help"], + cwd=tmp_path, + env={"PATH": os.defpath, "PYTHONPATH": f"{backend}:{backend.parent}"}, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert "--request-key" in result.stdout + assert not result.stderr diff --git a/backend/airweave/domains/native_ingestion/tests/test_publisher.py b/backend/airweave/domains/native_ingestion/tests/test_publisher.py new file mode 100644 index 0000000000..2f50fbb8e7 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_publisher.py @@ -0,0 +1,148 @@ +"""Staged publisher resumes durable HTTP progress after lost responses.""" + +# ruff: noqa: F811 +import httpx +import pytest +from evaluation.native_import import NativePublishError, publish_native +from sqlalchemy import func, select + +from airweave.api import deps +from airweave.api.v1.endpoints.native_sources import router as source_router +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.native_ingestion.models import SessionVersion +from airweave.domains.native_ingestion.source_service import NativeSources +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.domains.native_ingestion.tests.test_import_api import native_api # noqa: F401 +from airweave.domains.native_ingestion.tests.test_ingestion import snapshot +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob + + +class LostResponse(httpx.AsyncBaseTransport): + def __init__(self, inner, suffix): + self.inner = inner + self.suffix = suffix + self.dropped = False + + async def handle_async_request(self, request): + response = await self.inner.handle_async_request(request) + if not self.dropped and request.url.path.endswith(self.suffix): + self.dropped = True + await response.aclose() + raise httpx.ReadError("synthetic lost response", request=request) + return response + + async def aclose(self): + await self.inner.aclose() + + +def wire_sources(client): + app = client._transport.app + app.include_router(source_router, prefix="/native/sources") + container = app.dependency_overrides[deps.get_container]() + container.native_sources = NativeSources(NativeSourceStore()) + app.dependency_overrides[deps.get_container] = lambda: container + + +@pytest.mark.parametrize("lost", ["/pages", "/scopes/reconcile", "/complete"]) +async def test_lost_response_resume_and_changed_input_conflict( + native_api, database, lost, monkeypatch +): + client, _, source, _ = native_api + wire_sources(client) + client._transport = LostResponse(client._transport, lost) + import evaluation.native_import as publisher + + monkeypatch.setattr(publisher, "_PAGE_SIZE", 2) + originals = tuple(snapshot(str(index), owner_id="owner") for index in range(3)) + kwargs = { + "owner_id": "owner", + "dataset": "knowledge", + "collection": "native-api", + "request_key": "stable-publisher", + "snapshots": originals, + } + with pytest.raises(httpx.ReadError): + await publish_native(client, **kwargs) + result = await publish_native(client, **kwargs) + assert result.status == "completed" and result.summary.capture_complete + assert await publish_native(client, **kwargs) == result + with pytest.raises(NativePublishError, match="HTTP 409"): + await publish_native( + client, **{**kwargs, "snapshots": (snapshot("different", owner_id="owner"),)} + ) + async with database() as db: + assert ( + await db.scalar( + select(func.count()).select_from(Entity).where(Entity.sync_id == source.sync_id) + ) + == 3 + ) + records = (await db.scalars(select(Entity).where(Entity.sync_id == source.sync_id))).all() + assert all(record.record_revision == 1 for record in records) + assert result.summary.sequence == 3 + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == source.sync_id) + ) + == 1 + ) + + +@pytest.mark.parametrize("native_api", ["knowledge", "sessions"], indirect=True) +async def test_empty_dataset_and_empty_session(native_api): + client, _, source, _ = native_api + wire_sources(client) + snapshots = () + if source.binding.dataset == "sessions": + snapshots = ( + snapshot( + "session", + owner_id="owner", + identity=RecordIdentity(record_type="session", native_id="session"), + version=SessionVersion(revision=1, content_revision=0), + ), + ) + result = await publish_native( + client, + owner_id="owner", + dataset=source.binding.dataset, + collection="native-api", + request_key="empty", + snapshots=snapshots, + ) + assert result.status == "completed" + assert result.summary.completed_scopes == (2 if snapshots else 1) + + +async def test_preflight_rejects_mismatched_message_before_http(): + calls = [] + + def response(request): + calls.append(request) + return httpx.Response(500) + + parent = snapshot( + "session", + identity=RecordIdentity(record_type="session", native_id="session"), + version=SessionVersion(revision=1, content_revision=2), + ) + child = snapshot( + "message", + identity=RecordIdentity(record_type="message", native_id="message", container_id="session"), + parent=parent.identity, + version=SessionVersion(revision=1, content_revision=1), + ) + async with httpx.AsyncClient( + base_url="http://test/", transport=httpx.MockTransport(response) + ) as client: + with pytest.raises(NativePublishError, match="matching session"): + await publish_native( + client, + owner_id=parent.owner_id, + dataset="sessions", + collection="test", + request_key="invalid", + snapshots=(parent, child), + ) + assert calls == [] diff --git a/backend/airweave/domains/native_ingestion/tests/test_scope_api.py b/backend/airweave/domains/native_ingestion/tests/test_scope_api.py new file mode 100644 index 0000000000..d103261baf --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_scope_api.py @@ -0,0 +1,168 @@ +"""HTTP scope/page/reconcile flow using actual retained records and scan transactions.""" + +# Imported fixtures are resolved by pytest parameter name. +# ruff: noqa: F811 + +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.native_ingestion.models import SessionVersion +from airweave.domains.native_ingestion.tests.test_import_api import native_api # noqa: F401 +from airweave.domains.native_ingestion.tests.test_ingestion import snapshot +from airweave.models.entity import Entity +from airweave.models.sync_job import SyncJob + + +@pytest.mark.parametrize("coverage", ["bounded", "complete"]) +async def test_http_scope_pages_retry_read_and_reconcile(database, native_api, coverage): + client, _, source, logs = native_api + base = f"/native/sources/{source.source_connection_id}/imports/scope-key" + started = await client.put(base, json={"snapshot_id": "snapshot", "coverage": coverage}) + assert started.status_code == 200 + scope = {"record_type": "knowledge"} + assert (await client.post(base + "/scopes/read", json={"scope": scope})).status_code == 409 + opened = await client.put(base + "/scopes", json={"scope": scope}) + assert opened.status_code == 200, opened.text + assert (await client.put(base + "/scopes", json={"scope": scope})).json() == opened.json() + assert ( + await client.post( + base + "/scopes/reconcile", + json={ + "scope": scope, + "expected": opened.json()["version"], + }, + ) + ).status_code == 409 + original = snapshot(owner_id="owner", original={"body": "private-native-content नमस्ते"}) + body = { + "page_id": str(uuid4()), + "scope": scope, + "expected": opened.json()["version"], + "snapshots": [original.model_dump(mode="json")], + "cursor": {"position": 1}, + "final": True, + } + committed = await client.put(base + "/pages", json=body) + assert committed.status_code == 200, committed.text + assert (await client.put(base + "/pages", json=body)).json() == committed.json() + read = await client.post(base + "/scopes/read", json={"scope": scope}) + assert read.json()["last_page"] == committed.json() + assert read.json()["cursor"] == {"position": 1} + assert "digest" not in read.text and "fence" not in read.text + reconciled = await client.post( + base + "/scopes/reconcile", + json={ + "scope": scope, + "expected": committed.json()["version"], + }, + ) + assert reconciled.status_code == 200, reconciled.text + assert reconciled.json()["phase"] == "complete" + assert reconciled.json()["coverage"] == coverage + assert (await client.put(base + "/pages", json=body)).status_code == 409 + assert ( + await client.post(base + "/scopes/read", json={"scope": scope}) + ).json() == reconciled.json() + async with database() as db: + row = await db.scalar(select(Entity).where(Entity.sync_id == source.sync_id)) + assert row.record_revision == 1 + assert row.source_payload["original"] == original.original + assert "scope-key" not in str(logs) and "private-native-content" not in str(logs) + + +async def test_scope_and_page_reject_session_foreign_scope_and_client_fence(native_api): + client, ctx, source, _ = native_api + base = f"/native/sources/{source.source_connection_id}/imports/scope-key" + await client.put(base, json={"snapshot_id": "snapshot", "coverage": "bounded"}) + ctx.is_api_key_auth = False + assert ( + await client.put(base + "/scopes", json={"scope": {"record_type": "knowledge"}}) + ).status_code == 403 + ctx.is_api_key_auth = True + assert ( + await client.put(base + "/scopes", json={"scope": {"record_type": "email"}}) + ).status_code == 409 + scope = await client.put(base + "/scopes", json={"scope": {"record_type": "knowledge"}}) + body = { + "scope": {"record_type": "knowledge"}, + "page_id": str(uuid4()), + "expected": scope.json()["version"], + "snapshots": [], + "final": True, + "fence": {"job_id": str(uuid4())}, + } + assert (await client.put(base + "/pages", json=body)).status_code == 422 + body.pop("fence") + assert (await client.put(base + "/pages", json=body)).status_code == 200 + ctx.organization.id = uuid4() + assert ( + await client.post(base + "/scopes/read", json={"scope": {"record_type": "knowledge"}}) + ).status_code == 404 + + +@pytest.mark.parametrize("native_api", ["sessions"], indirect=True) +async def test_sessions_http_parent_and_child_scopes_reused_by_new_import(database, native_api): + client, _, source, _ = native_api + parent = snapshot( + owner_id="owner", + identity=RecordIdentity(record_type="session", native_id="s"), + version=SessionVersion(revision=1, content_revision=1), + ) + child = snapshot( + owner_id="owner", + identity=RecordIdentity(record_type="message", native_id="m", container_id="s"), + parent=parent.identity, + version=parent.version, + ) + root_scope = {"record_type": "session"} + child_scope = { + "record_type": "message", + "container_id": "s", + "parent": parent.identity.model_dump(mode="json"), + } + + async def upload(base, scope, item): + opened = await client.put(base + "/scopes", json={"scope": scope}) + assert opened.status_code == 200, opened.text + captured = await client.put( + base + "/pages", + json={ + "page_id": str(uuid4()), + "scope": scope, + "expected": opened.json()["version"], + "snapshots": [item.model_dump(mode="json")], + "final": True, + }, + ) + assert captured.status_code == 200, captured.text + reconciled = await client.post( + base + "/scopes/reconcile", + json={ + "scope": scope, + "expected": captured.json()["version"], + }, + ) + assert reconciled.status_code == 200, reconciled.text + return captured.json() + + base = f"/native/sources/{source.source_connection_id}/imports/first" + started = await client.put(base, json={"snapshot_id": "one", "coverage": "bounded"}) + assert (await client.put(base + "/scopes", json={"scope": child_scope})).status_code == 409 + await upload(base, root_scope, parent) + old_child = await upload(base, child_scope, child) + # Simulate external cancellation; dedicated cancellation HTTP is not implemented yet. + async with database() as db: + job = await db.get(SyncJob, started.json()["import_id"]) + job.status = "cancelled" + await db.commit() + later = base.replace("/first", "/second") + assert ( + await client.put(later, json={"snapshot_id": "two", "coverage": "bounded"}) + ).status_code == 200 + await upload(later, root_scope, parent) + new_child = await upload(later, child_scope, child) + assert new_child["version"]["sweep_id"] != old_child["version"]["sweep_id"] + assert new_child["changed"] == 0 and new_child["unchanged"] == 1 diff --git a/backend/airweave/domains/native_ingestion/tests/test_search.py b/backend/airweave/domains/native_ingestion/tests/test_search.py new file mode 100644 index 0000000000..9e4ad00b8e --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_search.py @@ -0,0 +1,96 @@ +"""Native publications use real SQL authority without a provider registry entry.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from sqlalchemy import select, update + +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.tests.helpers import publish_prepared +from airweave.domains.entities.canonical.tests.test_search_visibility import hit +from airweave.domains.native_ingestion.tests.test_ingestion import bind, ingest +from airweave.domains.native_ingestion.tests.test_projection import knowledge +from airweave.domains.search.canonical_visibility import visible_results +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.search.owned_models import OwnedSearchRequest +from airweave.domains.search.types import SearchResults +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection + + +async def test_native_search_requires_current_publication_and_source_access(database, source): + """Index transport is synthetic; capture, scope checks and publication are real SQL.""" + _, fence = source + await bind(database, fence) + await ingest(database, fence, knowledge()) + async with database() as db: + collection = await db.scalar( + select(Collection) + .join( + SourceConnection, SourceConnection.readable_collection_id == Collection.readable_id + ) + .where( + SourceConnection.sync_id == fence.sync_id, + Collection.organization_id == fence.organization_id, + ) + ) + assert collection is not None + store = CanonicalProjectionStore() + async with database() as db: + work = (await store.pending(db, fence.organization_id, fence.sync_id))[0] + generation = uuid4() + async with database() as db: + assert await publish_prepared(store, db, work, generation, 1, collection.id) + locator = ProjectionLocator( + record_id=work.record.id, + revision=work.record.revision, + pipeline_version=work.pipeline_version, + generation=generation, + part_index=0, + ) + candidate = hit(fence, locator.encode()) + candidate.airweave_system_metadata.source_name = "almanac" + registry = Mock() + registry.get.side_effect = AssertionError("Native content has no provider lifecycle") + executor = Mock() + executor.prepare_query = AsyncMock(return_value=None) + executor.execute = AsyncMock(return_value=SearchResults(results=[candidate])) + service = OwnedSearchService(executor, registry) + ctx = SimpleNamespace(organization=SimpleNamespace(id=fence.organization_id)) + request = OwnedSearchRequest(query="original", sync_ids=[fence.sync_id], mode="keyword") + result = await service.search(database, ctx, request) + assert len(result.items) == 1 + assert result.items[0].provider == "almanac" + assert result.items[0].identity.record_type == "knowledge" + async with database() as db: + assert await visible_results( + db, fence.organization_id, collection.readable_id, [candidate], registry + ) == [candidate] + legacy = candidate.model_copy(deep=True) + legacy.airweave_system_metadata.original_entity_id = "unversioned-native-id" + assert ( + await visible_results( + db, fence.organization_id, collection.readable_id, [legacy], registry + ) + == [] + ) + + async def revoke_during_retrieval(**kwargs): + async with database() as db: + await db.execute( + update(SourceConnection) + .where(SourceConnection.sync_id == fence.sync_id) + .values(is_authenticated=False) + ) + await db.commit() + return SearchResults(results=[candidate]) + + executor.execute.side_effect = revoke_during_retrieval + with pytest.raises(HTTPException) as error: + await service.search(database, ctx, request) + assert error.value.status_code == 404 + registry.get.assert_not_called() diff --git a/backend/airweave/domains/native_ingestion/tests/test_sources.py b/backend/airweave/domains/native_ingestion/tests/test_sources.py new file mode 100644 index 0000000000..c1f73d8301 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_sources.py @@ -0,0 +1,141 @@ +"""Actual SQL races, rollback and HTTP backend-authority gate for native sources.""" + +import asyncio +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest +from fastapi import FastAPI, HTTPException +from sqlalchemy import func, select + +from airweave.api import deps +from airweave.api.v1.endpoints.native_sources import router +from airweave.db.session import get_db +from airweave.domains.native_ingestion.source_models import ( + EnsureNativeSource, + native_source_id, + native_sync_id, +) +from airweave.domains.native_ingestion.source_service import NativeSources +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.models import Collection, Organization, SourceConnection, Sync +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +@pytest.fixture +async def native_setup(database): + org, foreign = uuid4(), uuid4() + async with database() as db: + db.add_all([Organization(id=org, name="Native"), Organization(id=foreign, name="Other")]) + metadata = VectorDbDeploymentMetadata( + dense_embedder="test", sparse_embedder="test", embedding_dimensions=3 + ) + db.add(metadata) + await db.flush() + for name, owner in (("one", org), ("two", org), ("foreign", foreign)): + db.add( + Collection( + name=name, + readable_id=name, + organization_id=owner, + vector_db_deployment_metadata_id=metadata.id, + ) + ) + await db.commit() + return org, foreign, NativeSources(NativeSourceStore()) + + +async def ensure(database, service, org, request): + async with database() as db: + return await service.ensure(db, org, request) + + +async def test_concurrent_same_identity_and_immutable_collection(database, native_setup): + org, _, service = native_setup + request = EnsureNativeSource(owner_id="owner", dataset="knowledge", collection="one") + results = await asyncio.gather(*(ensure(database, service, org, request) for _ in range(4))) + assert all(result == results[0] for result in results) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Sync)) == 1 + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + with pytest.raises(HTTPException) as conflict: + await ensure(database, service, org, request.model_copy(update={"collection": "two"})) + assert conflict.value.status_code == 409 + async with database() as db: + assert await service.get(db, org, results[0].source_connection_id) == results[0] + + +async def test_creation_rollback_and_partial_state_fail_closed(database, native_setup, monkeypatch): + org, _, service = native_setup + request = EnsureNativeSource(owner_id="owner", dataset="sessions", collection="one") + original = service.store.require + + async def fail(*args, **kwargs): + raise RuntimeError("synthetic failure after source insert") + + monkeypatch.setattr(service.store, "require", fail) + with pytest.raises(RuntimeError, match="after source insert"): + await ensure(database, service, org, request) + async with database() as db: + assert await db.scalar(select(func.count()).select_from(Sync)) == 0 + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 0 + monkeypatch.setattr(service.store, "require", original) + async with database() as db: + db.add( + Sync( + id=native_sync_id(native_source_id(org, request.binding())), + organization_id=org, + name="Unexplained partial", + ) + ) + await db.commit() + with pytest.raises(HTTPException) as conflict: + await ensure(database, service, org, request) + assert conflict.value.status_code == 409 + async with database() as db: + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 0 + + +async def test_owner_namespace_and_collection_organization_isolation(database, native_setup): + org, foreign, service = native_setup + request = EnsureNativeSource(owner_id="same-owner", dataset="knowledge", collection="one") + ours = await ensure(database, service, org, request) + theirs = await ensure( + database, service, foreign, request.model_copy(update={"collection": "foreign"}) + ) + assert ours.source_connection_id != theirs.source_connection_id + async with database() as db: + with pytest.raises(HTTPException) as missing: + await service.get(db, foreign, ours.source_connection_id) + assert missing.value.status_code == 404 + with pytest.raises(HTTPException) as missing: + await ensure(database, service, foreign, request) + assert missing.value.status_code == 404 + + +async def test_http_session_cannot_ensure_or_read_but_backend_key_can(database, native_setup): + org, _, service = native_setup + ctx = SimpleNamespace(organization=SimpleNamespace(id=org), is_api_key_auth=False) + app = FastAPI() + app.include_router(router, prefix="/native/sources") + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: ctx + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(native_sources=service) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://test" + ) as client: + body = {"owner_id": "owner", "dataset": "knowledge", "collection": "one"} + assert (await client.put("/native/sources", json=body)).status_code == 403 + assert (await client.get(f"/native/sources/{uuid4()}")).status_code == 403 + ctx.is_api_key_auth = True + created = await client.put("/native/sources", json=body) + assert created.status_code == 200, created.text + loaded = await client.get(f"/native/sources/{created.json()['source_connection_id']}") + assert loaded.status_code == 200 and loaded.json() == created.json() + assert (await client.put("/native/sources", json=body)).json() == created.json() diff --git a/backend/airweave/domains/native_ingestion/tests/test_terminal_api.py b/backend/airweave/domains/native_ingestion/tests/test_terminal_api.py new file mode 100644 index 0000000000..545c88ed7a --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_terminal_api.py @@ -0,0 +1,124 @@ +"""Durable native terminal outcomes and fencing through actual HTTP/SQL.""" + +# ruff: noqa: F811 +from uuid import uuid4 + +from sqlalchemy import select + +from airweave.domains.native_ingestion.tests.test_import_api import native_api # noqa: F401 +from airweave.models.sync import Sync +from airweave.models.sync_job import SyncJob + + +async def test_completion_retry_terminal_scope_read_and_new_import(database, native_api): + client, ctx, source, _ = native_api + base = f"/native/sources/{source.source_connection_id}/imports/first" + body = {"snapshot_id": "snapshot", "coverage": "bounded"} + await client.put(base, json=body) + assert (await client.post(base + "/complete")).status_code == 409 + scope = {"record_type": "knowledge"} + opened = (await client.put(base + "/scopes", json={"scope": scope})).json() + page = { + "scope": scope, + "page_id": str(uuid4()), + "expected": opened["version"], + "snapshots": [], + "final": True, + } + ack = (await client.put(base + "/pages", json=page)).json() + await client.post(base + "/scopes/reconcile", json={"scope": scope, "expected": ack["version"]}) + done = await client.post(base + "/complete") + assert done.status_code == 200, done.text + summary = done.json()["summary"] + assert summary["capture_complete"] and summary["indexing"] == "not_verified" + assert summary["coverage"] == "bounded" and summary["completed_scopes"] == 1 + assert (await client.post(base + "/complete")).json() == done.json() + assert (await client.post(base + "/scopes/read", json={"scope": scope})).status_code == 200 + assert (await client.put(base + "/pages", json=page)).status_code == 409 + newer = base.replace("/first", "/next") + await client.put(newer, json=body) + await client.put(newer + "/scopes", json={"scope": scope}) + assert (await client.post(base + "/scopes/read", json={"scope": scope})).status_code == 409 + assert (await client.post(base + "/complete")).json() == done.json() + assert (await client.get(base)).json() == done.json() + assert (await client.post(base + "/cancel")).json() == done.json() + async with database() as db: + sync = await db.get(Sync, source.sync_id) + assert (await db.get(SyncJob, sync.writer_job_id)).status == "running" + ctx.organization.id = uuid4() + assert (await client.post(base + "/complete")).status_code == 404 + assert (await client.post(newer + "/cancel")).status_code == 404 + + +async def test_cancel_retries_do_not_cancel_new_writer(native_api, database): + client, _, source, _ = native_api + base = f"/native/sources/{source.source_connection_id}/imports/cancelled" + body = {"snapshot_id": "snapshot", "coverage": "complete"} + await client.put(base, json=body) + scope = {"record_type": "knowledge"} + opened = (await client.put(base + "/scopes", json={"scope": scope})).json() + cancelled = await client.post(base + "/cancel") + assert cancelled.status_code == 200 + assert not cancelled.json()["summary"]["capture_complete"] + assert (await client.post(base + "/complete")).status_code == 409 + assert ( + await client.put( + base + "/pages", + json={ + "scope": scope, + "page_id": str(uuid4()), + "expected": opened["version"], + "snapshots": [], + "final": True, + }, + ) + ).status_code == 409 + newer = base.replace("/cancelled", "/next") + started = await client.put(newer, json=body) + assert started.status_code == 200 + assert (await client.post(base + "/cancel")).json() == cancelled.json() + async with database() as db: + job = await db.scalar(select(SyncJob).where(SyncJob.id == started.json()["import_id"])) + assert job.status == "running" + assert (await db.get(Sync, source.sync_id)).writer_job_id == job.id + + +async def test_terminal_summary_failure_rolls_back_cycle_completion( + native_api, database, monkeypatch +): + import airweave.domains.native_ingestion.import_store as module + + client, _, source, _ = native_api + base = f"/native/sources/{source.source_connection_id}/imports/rollback" + await client.put(base, json={"snapshot_id": "snapshot", "coverage": "bounded"}) + scope = {"record_type": "knowledge"} + opened = (await client.put(base + "/scopes", json={"scope": scope})).json() + page = ( + await client.put( + base + "/pages", + json={ + "scope": scope, + "page_id": str(uuid4()), + "expected": opened["version"], + "snapshots": [], + "final": True, + }, + ) + ).json() + await client.post( + base + "/scopes/reconcile", json={"scope": scope, "expected": page["version"]} + ) + original = module.NativeImportSummary + + def fail(**kwargs): + raise RuntimeError("synthetic summary persistence failure") + + monkeypatch.setattr(module, "NativeImportSummary", fail) + import pytest + + with pytest.raises(RuntimeError, match="summary persistence"): + await client.post(base + "/complete") + assert (await client.get(base)).json()["status"] == "running" + monkeypatch.setattr(module, "NativeImportSummary", original) + # Successful retry also proves the cursor was not left complete by the failed transaction. + assert (await client.post(base + "/complete")).status_code == 200 diff --git a/backend/airweave/domains/native_ingestion/tests/test_transcript_reconciliation.py b/backend/airweave/domains/native_ingestion/tests/test_transcript_reconciliation.py new file mode 100644 index 0000000000..4ab1498434 --- /dev/null +++ b/backend/airweave/domains/native_ingestion/tests/test_transcript_reconciliation.py @@ -0,0 +1,203 @@ +"""Explicit complete transcripts reconcile children within bounded observed roots.""" + +# Imported pytest fixture names are resolved through function parameters. +# ruff: noqa: F811 + +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_store import ScanConflict +from airweave.domains.entities.canonical.store import CanonicalRecordStore +from airweave.domains.native_ingestion.errors import NativeAdmissionError +from airweave.domains.native_ingestion.import_models import StartNativeImport +from airweave.domains.native_ingestion.import_service import NativeImports +from airweave.domains.native_ingestion.import_store import NativeImportStore +from airweave.domains.native_ingestion.models import SessionVersion +from airweave.domains.native_ingestion.page_models import CommitNativePage +from airweave.domains.native_ingestion.scope_models import BeginNativeScope, ReconcileNativeScope +from airweave.domains.native_ingestion.source_store import NativeSourceStore +from airweave.domains.native_ingestion.tests.test_imports import native # noqa: F401 +from airweave.domains.native_ingestion.tests.test_ingestion import ingest, snapshot +from airweave.models.entity import Entity + +pytestmark = pytest.mark.parametrize("native", ["sessions"], indirect=True) + + +def imports(): + return NativeImports(NativeImportStore(NativeSourceStore(), CanonicalRecordStore())) + + +def session(native_id, revision=1): + return snapshot( + native_id, + owner_id="owner", + identity=RecordIdentity(record_type="session", native_id=native_id), + version=SessionVersion(revision=revision, content_revision=revision), + ) + + +def message(parent, native_id): + return snapshot( + native_id, + owner_id="owner", + identity=RecordIdentity( + record_type="message", native_id=native_id, container_id=parent.identity.native_id + ), + parent=parent.identity, + version=parent.version, + ) + + +def child_scope(parent): + return CompletedScope( + record_type="message", container_id=parent.identity.native_id, parent=parent.identity + ) + + +async def begin(database, native, key, scope): + async with database() as db: + return await imports().begin_scope( + db, + native.organization_id, + native.source_connection_id, + key, + BeginNativeScope(scope=scope), + ) + + +async def page(database, native, key, state, items, *, final=True): + async with database() as db: + return await imports().page( + db, + native.organization_id, + native.source_connection_id, + key, + CommitNativePage( + page_id=uuid4(), + scope=state.scope, + expected=state.version, + snapshots=items, + final=final, + ), + ) + + +async def reconcile(database, native, key, scope, version): + async with database() as db: + return await imports().reconcile_scope( + db, + native.organization_id, + native.source_connection_id, + key, + ReconcileNativeScope(scope=scope, expected=version), + ) + + +async def capture_scope(database, native, key, scope, items): + state = await begin(database, native, key, scope) + ack = await page(database, native, key, state, items) + return await reconcile(database, native, key, scope, ack.version) + + +async def start(database, native, key, **intent): + async with database() as db: + return await imports().start( + db, + native.organization_id, + native.source_connection_id, + key, + StartNativeImport(snapshot_id=key, coverage="bounded", **intent), + ) + + +async def seed(database, native): + first, second = session("first"), session("second") + await start(database, native, "baseline") + await capture_scope( + database, native, "baseline", CompletedScope(record_type="session"), (first, second) + ) + await capture_scope( + database, + native, + "baseline", + child_scope(first), + (message(first, "kept"), message(first, "omitted")), + ) + await capture_scope( + database, native, "baseline", child_scope(second), (message(second, "unselected"),) + ) + async with database() as db: + await imports().finish(db, native.organization_id, native.source_connection_id, "baseline") + return first, second + + +async def rows(database, native): + async with database() as db: + values = await db.scalars(select(Entity).where(Entity.sync_id == native.sync_id)) + return {item.native_id: item for item in values} + + +@pytest.mark.parametrize("complete", [False, True]) +async def test_selected_transcript_policy_preserves_unselected_roots(database, native, complete): + await seed(database, native) + intent = {"transcript_coverage": "complete"} if complete else {} + started = await start(database, native, "selected", **intent) + assert await start(database, native, "selected", **intent) == started + parent = session("first", revision=2) + root = await capture_scope( + database, native, "selected", CompletedScope(record_type="session"), (parent,) + ) + assert root.coverage == "bounded" + scope = await capture_scope( + database, native, "selected", child_scope(parent), (message(parent, "kept"),) + ) + assert scope.coverage == ("complete" if complete else "bounded") + async with database() as db: + read = await imports().read_scope( + db, + native.organization_id, + native.source_connection_id, + "selected", + BeginNativeScope(scope=scope.scope), + ) + assert read.coverage == scope.coverage + result = await imports().finish( + db, native.organization_id, native.source_connection_id, "selected" + ) + assert result.summary.coverage == "bounded" and result.summary.capture_complete + records = await rows(database, native) + assert (records["omitted"].deleted_at is not None) == complete + assert records["omitted"].removal_reason == ("absent" if complete else None) + assert records["kept"].deleted_at is None + for name in ("second", "unselected"): + assert records[name].deleted_at is None and records[name].record_revision == 1 + + +async def test_incomplete_failed_and_parent_changed_scan_cannot_reconcile_absence(database, native): + old_parent, _ = await seed(database, native) + await start(database, native, "selected", transcript_coverage="complete") + parent = session("first", revision=2) + await capture_scope( + database, native, "selected", CompletedScope(record_type="session"), (parent,) + ) + state = await begin(database, native, "selected", child_scope(parent)) + ack = await page(database, native, "selected", state, (message(parent, "kept"),), final=False) + state = state.model_copy(update={"version": ack.version}) + with pytest.raises(NativeAdmissionError, match="version must match"): + await page(database, native, "selected", state, (message(old_parent, "stale"),)) + with pytest.raises(ScanConflict, match="fully collected"): + await reconcile(database, native, "selected", state.scope, ack.version) + async with database() as db: + saved = await imports().store.active( + db, native.organization_id, native.source_connection_id, "selected" + ) + await ingest(database, saved.fence, session("first", revision=3)) + with pytest.raises((CycleConflict, ScanConflict)): + await page(database, native, "selected", state, ()) + records = await rows(database, native) + assert records["omitted"].deleted_at is None and records["omitted"].record_revision == 1 + assert "stale" not in records diff --git a/backend/airweave/domains/oauth/callback_service.py b/backend/airweave/domains/oauth/callback_service.py index a682eda78b..7c889a1c86 100644 --- a/backend/airweave/domains/oauth/callback_service.py +++ b/backend/airweave/domains/oauth/callback_service.py @@ -531,6 +531,7 @@ async def _complete_connection_common( # noqa: C901 collection_id=collection.id, collection_readable_id=collection.readable_id, source_entry=source_entry, + source_config=validated_config, schedule_config=schedule_config, run_immediately=True, ctx=ctx, diff --git a/backend/airweave/domains/oauth/tests/test_callback_service.py b/backend/airweave/domains/oauth/tests/test_callback_service.py index a2b1a762c1..91c0581ed6 100644 --- a/backend/airweave/domains/oauth/tests/test_callback_service.py +++ b/backend/airweave/domains/oauth/tests/test_callback_service.py @@ -25,8 +25,8 @@ from airweave.domains.organizations.fakes.repository import FakeOrganizationRepository from airweave.domains.source_connections.fakes.repository import FakeSourceConnectionRepository from airweave.domains.sources.exceptions import SourceNotFoundError, SourceValidationError -from airweave.domains.syncs.jobs.fakes.repository import FakeSyncJobRepository from airweave.domains.syncs.fakes.repository import FakeSyncRepository +from airweave.domains.syncs.jobs.fakes.repository import FakeSyncJobRepository from airweave.models.connection_init_session import ConnectionInitSession, ConnectionInitStatus from airweave.models.organization import Organization from airweave.models.source_connection import SourceConnection @@ -1122,6 +1122,7 @@ async def test_triggers_workflow_when_pending_job_exists(self): job_id = uuid4() sync_job = SyncJob( + provisioning_generation=0, id=job_id, sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1255,6 +1256,7 @@ async def test_running_job_skips_workflow(self): from airweave.models.sync_job import SyncJob sync_job = SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.RUNNING, @@ -1316,6 +1318,7 @@ async def test_missing_connection_id_raises_value_error(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1407,6 +1410,7 @@ async def test_pending_job_with_connection_executes_event_payload_path(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1575,6 +1579,7 @@ async def test_no_collection_skips_workflow(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1743,6 +1748,7 @@ async def test_sync_deferred_when_claim_token_set(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1823,6 +1829,7 @@ async def test_sync_triggered_when_no_claim_token(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -1953,6 +1960,7 @@ async def test_two_step_contract_callback_defers_then_verify_triggers(self): sync_id, [ SyncJob( + provisioning_generation=0, id=uuid4(), sync_id=sync_id, status=SyncJobStatus.PENDING, @@ -2241,9 +2249,7 @@ async def test_complete_connection_common_activates_sync(self): db = AsyncMock() ctx = _ctx() - with patch( - "airweave.domains.oauth.callback_service.UnitOfWork" - ) as mock_uow_cls: + with patch("airweave.domains.oauth.callback_service.UnitOfWork") as mock_uow_cls: mock_uow = AsyncMock() mock_uow.session = AsyncMock() mock_uow.__aenter__ = AsyncMock(return_value=mock_uow) diff --git a/backend/airweave/domains/ocr/fallback.py b/backend/airweave/domains/ocr/fallback.py index 1afa9f407e..7392b00a4d 100644 --- a/backend/airweave/domains/ocr/fallback.py +++ b/backend/airweave/domains/ocr/fallback.py @@ -54,9 +54,9 @@ def __init__( async def convert_batch(self, file_paths: List[str]) -> Dict[str, Optional[str]]: """Convert files to markdown, trying providers in order. - Skips circuit-broken providers. On failure, records the failure - and tries the next provider. If all providers are exhausted, - returns None for every file. + Retains successful results and sends only unresolved files to later providers. + Skips circuit-broken providers. Unresolved files remain None when the chain + is exhausted; successful files are never processed or charged twice. Args: file_paths: Local file paths to convert. @@ -64,29 +64,36 @@ async def convert_batch(self, file_paths: List[str]) -> Dict[str, Optional[str]] Returns: Mapping of ``file_path -> markdown`` (``None`` on failure). """ + combined = {path: None for path in file_paths} for provider_key, provider in self._providers: + pending = [path for path, result in combined.items() if result is None] + if not pending: + break if not await self._circuit_breaker.is_available(provider_key): logger.info(f"[FallbackOCR] Skipping '{provider_key}' (circuit-broken)") continue try: - results = await provider.convert_batch(file_paths) - - if results and all(v is None for v in results.values()): + results = await provider.convert_batch(pending) + succeeded = { + path: results[path] for path in pending if results.get(path) is not None + } + if succeeded: + combined.update(succeeded) + await self._circuit_breaker.record_success(provider_key) + else: await self._circuit_breaker.record_failure(provider_key) logger.warning( - f"[FallbackOCR] '{provider_key}' returned all-None results " - f"({len(results)} files), trying next provider" + f"[FallbackOCR] '{provider_key}' produced no usable results " + f"for {len(pending)} files, trying next provider" ) - continue - - await self._circuit_breaker.record_success(provider_key) - return results except Exception as exc: await self._circuit_breaker.record_failure(provider_key) logger.warning( - f"[FallbackOCR] '{provider_key}' failed ({exc}), trying next provider" + f"[FallbackOCR] '{provider_key}' failed ({type(exc).__name__}), " + "trying next provider" ) - logger.error("[FallbackOCR] All OCR providers unavailable or failed") - return {path: None for path in file_paths} + if any(result is None for result in combined.values()): + logger.warning("[FallbackOCR] Some files have no successful OCR result") + return combined diff --git a/backend/airweave/domains/ocr/local.py b/backend/airweave/domains/ocr/local.py new file mode 100644 index 0000000000..db3ca08808 --- /dev/null +++ b/backend/airweave/domains/ocr/local.py @@ -0,0 +1,109 @@ +"""Optional local OCR provider with one bounded, disposable process per input.""" + +import asyncio +import re +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from airweave.domains.ocr.local_worker import ( + MAX_INPUT_BYTES, + MAX_OUTPUT_BYTES, + SUPPORTED_EXTENSIONS, +) + +OCR_TIMEOUT_SECONDS = 120 +WORKER_PATH = Path(__file__).with_name("local_worker.py") + + +async def _stop(process: asyncio.subprocess.Process) -> None: + """Reap the worker after killing it, including a process that exited concurrently.""" + if process.returncode is None: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +class LocalOcrProvider: + """Use explicitly provisioned Tesseract language files without runtime downloads.""" + + def __init__(self, tessdata_path: str | Path, languages: tuple[str, ...] = ("eng",)) -> None: + """Fail configuration early for absent models or unsafe language selectors.""" + directory = Path(tessdata_path).resolve() + if not directory.is_dir() or not languages: + raise ValueError("Local OCR requires a tessdata directory and languages") + for language in languages: + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", language): + raise ValueError("Invalid local OCR language selector") + model = directory / f"{language}.traineddata" + if not model.is_file() or model.stat().st_size == 0: + raise ValueError("Local OCR language model is unavailable") + try: + with model.open("rb") as handle: + handle.read(1) + except OSError: + raise ValueError("Local OCR language model is unreadable") from None + self._tessdata = directory + self._languages = "+".join(languages) + + async def convert_batch(self, file_paths: list[str]) -> dict[str, str | None]: + """Process sequentially; each failed file remains available to the fallback chain.""" + results = {} + for path in file_paths: + results[path] = await self._convert(path) + return results + + async def _convert(self, file_path: str) -> str | None: + try: + path = Path(file_path).resolve() + if ( + path.suffix.lower() not in SUPPORTED_EXTENSIONS + or not path.is_file() + or path.stat().st_size > MAX_INPUT_BYTES + ): + return None + except (OSError, RuntimeError): + return None + with TemporaryDirectory(prefix="airweave-local-ocr-") as directory: + output = Path(directory) / "text.txt" + spawn = asyncio.create_task( + asyncio.create_subprocess_exec( + sys.executable, + "-I", + str(WORKER_PATH), + "--input", + str(path), + "--output", + str(output), + "--tessdata", + str(self._tessdata), + "--languages", + self._languages, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + ) + try: + process = await asyncio.shield(spawn) + except asyncio.CancelledError: + process = await spawn + await _stop(process) + raise + except OSError: + return None + try: + await asyncio.wait_for(process.wait(), timeout=OCR_TIMEOUT_SECONDS) + if process.returncode != 0: + return None + with output.open("rb") as handle: + data = handle.read(MAX_OUTPUT_BYTES + 1) + if len(data) > MAX_OUTPUT_BYTES: + return None + return data.decode("utf-8").strip() or None + except (OSError, UnicodeDecodeError, TimeoutError): + return None + finally: + await _stop(process) diff --git a/backend/airweave/domains/ocr/local_worker.py b/backend/airweave/domains/ocr/local_worker.py new file mode 100644 index 0000000000..46c0c894fd --- /dev/null +++ b/backend/airweave/domains/ocr/local_worker.py @@ -0,0 +1,89 @@ +"""Isolated bounded PyMuPDF/Tesseract process; no application imports or downloads.""" + +import argparse +import math +from pathlib import Path + +DPI = 150 +# Match storage/limits.py without importing the application in this isolated worker. +MAX_INPUT_BYTES = 200 * 1024 * 1024 +MAX_PAGES = 200 +MAX_PAGE_PIXELS = 20_000_000 +MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +SUPPORTED_EXTENSIONS = frozenset( + {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"} +) + + +def _check_page(width: float, height: float) -> None: + """Reject excessive render surfaces before constructing any page pixmap.""" + if not all(math.isfinite(value) and value > 0 for value in (width, height)): + raise ValueError("Invalid page dimensions") + pixels = math.ceil(width * DPI / 72) * math.ceil(height * DPI / 72) + if pixels > MAX_PAGE_PIXELS: + raise ValueError("Page render limit exceeded") + + +def _require_single_frame(path: Path) -> None: + """PyMuPDF image opening must not silently omit later container frames.""" + from PIL import Image + + with Image.open(path) as image: + try: + image.seek(1) + except EOFError: + return + raise ValueError("Multi-frame images are not supported") + + +def extract(path: Path, tessdata: Path, languages: str) -> bytes: + """Return complete UTF-8 text or fail; partial results are never published.""" + import pymupdf + + if path.suffix.lower() not in SUPPORTED_EXTENSIONS: + raise ValueError("Unsupported input format") + if path.stat().st_size > MAX_INPUT_BYTES: + raise ValueError("Input size limit exceeded") + with pymupdf.open(path) as document: + if document.needs_pass or not 1 <= document.page_count <= MAX_PAGES: + raise ValueError("Unsupported document bounds or encryption") + if not document.is_pdf: + _require_single_frame(path) + for page in document: + _check_page(page.rect.width, page.rect.height) + output = bytearray() + for page in document: + textpage = page.get_textpage_ocr( + language=languages, dpi=DPI, full=True, tessdata=str(tessdata) + ) + text = page.get_text("text", textpage=textpage).strip().encode("utf-8") + separator = b"\n\n" if output and text else b"" + if len(output) + len(separator) + len(text) > MAX_OUTPUT_BYTES: + raise ValueError("OCR output limit exceeded") + output.extend(separator) + output.extend(text) + if not output: + raise ValueError("OCR produced no text") + return bytes(output) + + +def main() -> int: + """Only a successfully completed extraction may create the private result file.""" + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--tessdata", required=True, type=Path) + parser.add_argument("--languages", required=True) + args = parser.parse_args() + try: + content = extract(args.input, args.tessdata, args.languages) + with args.output.open("xb") as destination: + destination.write(content) + return 0 + except Exception: + # Provider/native exception messages may contain document text or private paths. + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/airweave/domains/ocr/tests/test_fallback.py b/backend/airweave/domains/ocr/tests/test_fallback.py index dcafe63ebb..ab76d22b99 100644 --- a/backend/airweave/domains/ocr/tests/test_fallback.py +++ b/backend/airweave/domains/ocr/tests/test_fallback.py @@ -137,8 +137,8 @@ async def test_fallback_ocr(case: Case): @pytest.mark.asyncio -async def test_partial_none_does_not_trigger_fallback(): - """When a provider returns some None and some real results, don't fall back.""" +async def test_partial_failure_retries_only_unresolved_files(): + """Keep successful local work while later providers handle unsupported files.""" primary = FakeOcrProvider( overrides={"/tmp/a.pdf": "# A", "/tmp/b.pdf": None}, default_markdown=None, @@ -153,10 +153,10 @@ async def test_partial_none_does_not_trigger_fallback(): result = await fallback.convert_batch(["/tmp/a.pdf", "/tmp/b.pdf"]) assert result["/tmp/a.pdf"] == "# A" - assert result["/tmp/b.pdf"] is None - assert cb.successes == ["primary"] + assert result["/tmp/b.pdf"] == "# Secondary" + assert cb.successes == ["primary", "secondary"] assert cb.failures == [] - assert secondary.call_count == 0 + assert secondary.calls == [["/tmp/b.pdf"]] def test_rejects_empty_providers(): diff --git a/backend/airweave/domains/ocr/tests/test_local.py b/backend/airweave/domains/ocr/tests/test_local.py new file mode 100644 index 0000000000..fb79815556 --- /dev/null +++ b/backend/airweave/domains/ocr/tests/test_local.py @@ -0,0 +1,182 @@ +"""Local OCR isolation, bounded input/output, and optional real model qualification.""" + +import asyncio +import os +from pathlib import Path + +import pymupdf +import pytest +from PIL import Image + +from airweave.domains.ocr import local, local_worker +from airweave.domains.ocr.local import LocalOcrProvider +from airweave.domains.ocr.protocols import OcrProvider + + +@pytest.fixture +def models(tmp_path): + directory = tmp_path / "tessdata" + directory.mkdir() + (directory / "eng.traineddata").write_bytes(b"synthetic model for process tests") + return directory + + +def pdf(path, *, pages=1, size=300, text="Local OCR test"): + with pymupdf.open() as document: + for _ in range(pages): + page = document.new_page(width=size, height=size) + page.insert_text((30, 50), text) + document.save(path) + return path + + +def test_configuration_requires_explicit_safe_models(models): + assert isinstance(LocalOcrProvider(models), OcrProvider) + for languages in ((), ("../eng",), ("eng+hin",), ("hin",)): + with pytest.raises(ValueError): + LocalOcrProvider(models, languages=languages) + with pytest.raises(ValueError): + LocalOcrProvider(models / "missing") + + +async def test_unsupported_input_does_not_start_process(models, tmp_path, monkeypatch): + async def forbidden(*args, **kwargs): + raise AssertionError("Unsupported input started OCR") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden) + path = tmp_path / "private.txt" + path.write_text("not an OCR image") + large = tmp_path / "large.pdf" + with large.open("wb") as handle: + handle.truncate(local_worker.MAX_INPUT_BYTES + 1) + assert await LocalOcrProvider(models).convert_batch([str(path), str(large)]) == { + str(path): None, + str(large): None, + } + with pytest.raises(ValueError, match="Input size"): + local_worker.extract(large, models, "eng") + + +@pytest.mark.parametrize("cancel", [False, True], ids=["timeout", "cancellation"]) +async def test_worker_is_killed_reaped_and_scratch_removed(models, tmp_path, monkeypatch, cancel): + worker = tmp_path / "sleeper.py" + worker.write_text("import time\ntime.sleep(60)\n") + monkeypatch.setattr(local, "WORKER_PATH", worker) + monkeypatch.setattr(local, "OCR_TIMEOUT_SECONDS", 0.1 if not cancel else 120) + actual_spawn = asyncio.create_subprocess_exec + started = asyncio.Event() + processes, outputs = [], [] + + async def spawn(*args, **kwargs): + assert kwargs["stderr"] == asyncio.subprocess.DEVNULL + assert kwargs["stdout"] == asyncio.subprocess.DEVNULL + assert args[1] == "-I" + process = await actual_spawn(*args, **kwargs) + processes.append(process) + outputs.append(Path(args[args.index("--output") + 1])) + started.set() + return process + + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + path = pdf(tmp_path / "private.pdf") + task = asyncio.create_task(LocalOcrProvider(models).convert_batch([str(path)])) + await started.wait() + if cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + assert await task == {str(path): None} + assert processes[0].returncode is not None + assert not outputs[0].parent.exists() + + +def test_render_limits_and_encryption_fail_before_ocr(tmp_path, models, monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("Unsafe page reached rendering") + + monkeypatch.setattr(pymupdf.Page, "get_textpage_ocr", forbidden) + paths = [pdf(tmp_path / "too-many.pdf", pages=201), pdf(tmp_path / "too-big.pdf", size=10000)] + encrypted = tmp_path / "encrypted.pdf" + with pymupdf.open() as document: + document.new_page() + document.save( + encrypted, encryption=pymupdf.PDF_ENCRYPT_AES_256, owner_pw="owner", user_pw="secret" + ) + paths.append(encrypted) + for path in paths: + with pytest.raises(ValueError): + local_worker.extract(path, models, "eng") + + +def test_multiframe_images_are_not_silently_truncated(tmp_path, models): + path = tmp_path / "frames.tiff" + Image.new("RGB", (10, 10)).save(path, save_all=True, append_images=[Image.new("RGB", (10, 10))]) + with pytest.raises(ValueError, match="Multi-frame"): + local_worker.extract(path, models, "eng") + + +def test_output_limit_rejects_whole_result(tmp_path, models, monkeypatch): + def synthetic_ocr(page, *, full, dpi, **kwargs): + assert full is True and dpi == 150 + return page.get_textpage() + + monkeypatch.setattr(pymupdf.Page, "get_textpage_ocr", synthetic_ocr) + monkeypatch.setattr(local_worker, "MAX_OUTPUT_BYTES", 3) + with pytest.raises(ValueError, match="output limit"): + local_worker.extract(pdf(tmp_path / "text.pdf"), models, "eng") + + +async def test_real_local_ocr_with_explicit_models(tmp_path): + directory = os.environ.get("LOCAL_OCR_TEST_TESSDATA") + if not directory: + pytest.skip("Set LOCAL_OCR_TEST_TESSDATA to explicitly qualify real local OCR") + path = tmp_path / "scan.pdf" + with pymupdf.open() as born_digital: + page = born_digital.new_page(width=400, height=150) + page.insert_text((30, 65), "LOCAL OCR ORIGINAL 12345", fontsize=18) + raster = page.get_pixmap(matrix=pymupdf.Matrix(2, 2)).tobytes("png") + with pymupdf.open() as scanned: + page = scanned.new_page(width=400, height=150) + page.insert_image(page.rect, stream=raster) + scanned.save(path) + image_path = tmp_path / "scan.png" + image_path.write_bytes(raster) + tiff_path = tmp_path / "scan.tiff" + with Image.open(image_path) as image: + image.save(tiff_path) + result = await LocalOcrProvider(directory).convert_batch( + [str(path), str(image_path), str(tiff_path)] + ) + assert "LOCAL OCR ORIGINAL 12345" in result[str(path)] + assert "LOCAL OCR ORIGINAL 12345" in result[str(image_path)] + assert "LOCAL OCR ORIGINAL 12345" in result[str(tiff_path)] + + +async def test_failed_file_does_not_discard_other_results(models, tmp_path, monkeypatch): + worker = tmp_path / "controlled.py" + worker.write_text( + "import pathlib, sys\n" + "args = dict(zip(sys.argv[1::2], sys.argv[2::2]))\n" + "if pathlib.Path(args['--input']).name == 'broken.pdf':\n" + " print('synthetic private diagnostic', file=sys.stderr)\n" + " raise SystemExit(1)\n" + "pathlib.Path(args['--output']).write_text('Recognized text')\n" + ) + monkeypatch.setattr(local, "WORKER_PATH", worker) + paths = [pdf(tmp_path / name) for name in ("first.pdf", "broken.pdf", "last.pdf")] + actual_spawn = asyncio.create_subprocess_exec + processes = [] + + async def spawn(*args, **kwargs): + assert all(process.returncode is not None for process in processes) + process = await actual_spawn(*args, **kwargs) + processes.append(process) + return process + + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + assert await LocalOcrProvider(models).convert_batch(list(map(str, paths))) == { + str(paths[0]): "Recognized text", + str(paths[1]): None, + str(paths[2]): "Recognized text", + } diff --git a/backend/airweave/domains/owned_provisioning/__init__.py b/backend/airweave/domains/owned_provisioning/__init__.py new file mode 100644 index 0000000000..4f048b3711 --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/__init__.py @@ -0,0 +1 @@ +"""Durable, idempotent desired state for Almanac-owned source connections.""" diff --git a/backend/airweave/domains/owned_provisioning/guard.py b/backend/airweave/domains/owned_provisioning/guard.py new file mode 100644 index 0000000000..4193a9961c --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/guard.py @@ -0,0 +1,48 @@ +"""Legacy editing must not bypass the generation-controlled account lifecycle.""" + +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.models.owned_provisioning import OwnedProvisioning +from airweave.models.source_connection import SourceConnection + + +def require_provider_source(short_name: str) -> None: + """Native imports cannot use provider configuration, OAuth or pull-sync routes.""" + if short_name == "almanac": + raise HTTPException(status_code=409, detail="Manage this source through its native import") + + +async def require_unmanaged_source( + db: AsyncSession, source_id: UUID, organization: UUID, *, short_name: str +) -> None: + """Owned source identity/configuration is writable only through ensure.""" + require_provider_source(short_name) + owned = await db.scalar( + select(OwnedProvisioning.id).where( + OwnedProvisioning.source_connection_id == source_id, + OwnedProvisioning.organization_id == organization, + ) + ) + if owned is not None: + raise HTTPException( + status_code=409, detail="Manage this source through its Almanac account" + ) + + +async def require_provider_sync(db: AsyncSession, sync_id: UUID, organization: UUID) -> None: + """Check the job's actual sync owner, not a caller-supplied source URL.""" + native = await db.scalar( + select(SourceConnection.id) + .where( + SourceConnection.sync_id == sync_id, + SourceConnection.organization_id == organization, + SourceConnection.short_name == "almanac", + ) + .limit(1) + ) + if native is not None: + raise HTTPException(status_code=409, detail="Cancel this job through its native import") diff --git a/backend/airweave/domains/owned_provisioning/models.py b/backend/airweave/domains/owned_provisioning/models.py new file mode 100644 index 0000000000..05afea45a3 --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/models.py @@ -0,0 +1,246 @@ +"""Backend-only desired account capture contract; credentials remain in Composio.""" + +from typing import Literal +from uuid import UUID + +from croniter import croniter +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + ValidationInfo, + field_validator, + model_validator, +) + + +class ManagedSource(BaseModel): + """Native identity is supplied only by Almanac's verified account authority.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + provider: Literal[ + "gmail", + "google_calendar", + "google_drive", + "slack", + "outlook_mail", + "outlook_calendar", + "stripe", + "linear", + "attio", + "github", + "notion", + ] + expected_identity: str = Field(min_length=1, max_length=512) + expected_user_identity: str | None = Field(default=None, min_length=1, max_length=512) + collection: str = Field(min_length=1, max_length=255) + auth_provider: str = Field(min_length=1, max_length=255) + connected_account_id: str = Field(min_length=1, max_length=255) + auth_config_id: str = Field(min_length=1, max_length=255) + user_id: str = Field(min_length=1, max_length=255) + config: dict[str, JsonValue] = Field(default_factory=dict) + cron: str = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def native_principal(self): + """Workspace-scoped grants retain the native member or bot, not broker user_id.""" + if (self.provider in {"slack", "notion"}) != (self.expected_user_identity is not None): + raise ValueError("Slack and Notion require an expected native user or bot identity") + if self.provider in {"stripe", "linear", "attio", "github", "notion"}: + self.source_config() # Validate native scope before admission, not during delivery. + return self + + @field_validator("expected_identity") + @classmethod + def canonical_identity(cls, value: str, info: ValidationInfo) -> str: + """Native IDs have one canonical spelling across admission and reconnect.""" + if info.data.get("provider") == "github": + if not value.isascii() or not value.isdecimal() or int(value) <= 0: + raise ValueError("GitHub identity must be a positive numeric user ID") + return str(int(value)) + if info.data.get("provider") in {"linear", "attio", "notion"}: + return str(UUID(value)) + return value + + @field_validator("expected_user_identity") + @classmethod + def canonical_member(cls, value: str | None, info: ValidationInfo) -> str | None: + """Notion bot UUID is part of the verified native account pair.""" + if value is not None and info.data.get("provider") == "notion": + return str(UUID(value)) + return value + + @field_validator("cron") + @classmethod + def valid_schedule(cls, value: str) -> str: + """Use the existing scheduler dependency rather than inventing cron parsing.""" + if len(value.split()) != 5 or not croniter.is_valid(value): + raise ValueError("Use a valid five-field cron schedule") + return value + + def source_config(self) -> dict[str, JsonValue]: + """Expected identity cannot be overridden inside unstructured provider config.""" + if self.provider == "notion": + from airweave.platform.configs.config import NotionConfig + + return NotionConfig.model_validate( + { + **self.config, + "expected_workspace_id": self.expected_identity, + "expected_bot_id": self.expected_user_identity, + } + ).model_dump(mode="json") + if self.provider == "github": + from airweave.platform.configs.config import GitHubConfig + + parsed = GitHubConfig.model_validate( + {**self.config, "expected_user_id": int(self.expected_identity)} + ) + value = parsed.model_dump(mode="json") + value["repositories"] = sorted( + value["repositories"], key=lambda item: item["repository_id"] + ) + return value + if self.provider in {"linear", "attio"}: + from airweave.platform.configs.config import AttioConfig, LinearConfig + + config_type = LinearConfig if self.provider == "linear" else AttioConfig + parsed = config_type.model_validate( + {**self.config, "workspace_id": self.expected_identity} + ) + value = parsed.model_dump(mode="json") + if self.provider == "linear": + value["team_ids"] = sorted(value["team_ids"]) + return value + if self.provider == "stripe": + from airweave.platform.configs.config import StripeCaptureConfig, StripeConfig + + binding = StripeCaptureConfig.model_validate( + {**self.config, "expected_account_id": self.expected_identity} + ) + return StripeConfig(original_capture=binding).model_dump(mode="json") + if self.provider in {"outlook_mail", "outlook_calendar"}: + return { + **self.config, + "expected_principal_id": self.expected_identity, + "capture_originals": True, + } + if self.provider == "slack": + return { + **self.config, + "expected_team_id": self.expected_identity, + "expected_user_id": self.expected_user_identity, + } + field = { + "gmail": "expected_mailbox", + "google_calendar": "expected_primary_calendar_id", + "google_drive": "expected_permission_id", + }[self.provider] + return {**self.config, field: self.expected_identity} + + def auth_config(self) -> dict[str, str]: + """Selectors contain no provider tokens or API keys.""" + return { + "account_id": self.connected_account_id, + "auth_config_id": self.auth_config_id, + "user_id": self.user_id, + } + + +class EnsureSource(BaseModel): + """Monotonic account authority request, safe for exact retries.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + generation: int = Field(ge=1, strict=True) + state: Literal["active", "paused", "disconnected"] + source: ManagedSource | None = None + + @model_validator(mode="after") + def active_source(self) -> "EnsureSource": + """Inactive requests never replace identity or credentials.""" + if self.state == "active" and self.source is None: + raise ValueError("An active account requires its verified source specification") + if self.state != "active" and self.source is not None: + raise ValueError("Inactive state does not accept new credentials or identity") + return self + + +class ProvisionedSource(BaseModel): + """Committed identifiers and delivery state, not proof of capture completion.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + account_id: UUID + organization_id: UUID + generation: int + observed_generation: int + state: Literal["pending", "ready", "paused", "disconnected"] + source_connection_id: UUID | None + sync_id: UUID | None + expected_identity: str | None + expected_user_identity: str | None = None + + +def _workspace_principal(provider: str, config: dict) -> str: + """Both workspace sources retain canonical UUIDs in their validated native config.""" + from airweave.platform.configs.config import AttioConfig, LinearConfig + + schema = LinearConfig if provider == "linear" else AttioConfig + return str(schema.model_validate(config).workspace_id) + + +def native_principal(provider: str, config: dict) -> tuple[str, str | None]: + """Read the original attested principal from the protected source config.""" + if provider == "notion": + from airweave.platform.configs.config import NotionConfig + + notion = NotionConfig.model_validate(config) + return str(notion.expected_workspace_id), str(notion.expected_bot_id) + if provider == "github": + from airweave.platform.configs.config import GitHubConfig + + return str(GitHubConfig.model_validate(config).expected_user_id), None + if provider in {"linear", "attio"}: + return _workspace_principal(provider, config), None + return _account_principal(provider, config) + + +def _account_principal(provider: str, config: dict) -> tuple[str, str | None]: + """Validate the established provider-specific account identity contracts.""" + from airweave.platform.configs.config import ( + GmailConfig, + GoogleCalendarConfig, + GoogleDriveConfig, + OutlookCalendarConfig, + OutlookMailConfig, + SlackConfig, + StripeConfig, + ) + + user = None + match provider: + case "gmail": + identity = GmailConfig.model_validate(config).expected_mailbox + case "google_calendar": + identity = GoogleCalendarConfig.model_validate(config).expected_primary_calendar_id + case "google_drive": + identity = GoogleDriveConfig.model_validate(config).expected_permission_id + case "outlook_mail" | "outlook_calendar": + config_type = OutlookMailConfig if provider == "outlook_mail" else OutlookCalendarConfig + outlook = config_type.model_validate(config) + if not outlook.capture_originals: + raise ValueError("Owned Outlook source must capture originals") + identity = outlook.expected_principal_id + case "stripe": + stripe = StripeConfig.model_validate(config) + if stripe.original_capture is None: + raise ValueError("Owned Stripe source must capture originals") + identity = stripe.original_capture.expected_account_id + case "slack": + slack = SlackConfig.model_validate(config) + identity, user = slack.expected_team_id, slack.expected_user_id + case _: + raise ValueError("Unsupported owned source provider") + if identity is None: + raise ValueError("Owned source has no trusted native identity") + return identity, user diff --git a/backend/airweave/domains/owned_provisioning/service.py b/backend/airweave/domains/owned_provisioning/service.py new file mode 100644 index 0000000000..c2a7496cdc --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/service.py @@ -0,0 +1,223 @@ +"""Reconcile committed account intent using existing native validation and Temporal.""" + +import asyncio +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from temporalio.exceptions import WorkflowAlreadyStartedError + +from airweave import schemas +from airweave.api.context import ApiContext +from airweave.core.datetime_utils import utc_now_naive +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.owned_provisioning.models import ( + EnsureSource, + ProvisionedSource, + native_principal, +) +from airweave.domains.owned_provisioning.store import ProvisioningStore, locked_intent +from airweave.domains.sources.protocols import SourceLifecycleServiceProtocol +from airweave.domains.syncs.jobs.protocols import SyncJobRepositoryProtocol +from airweave.domains.syncs.protocols import SyncRepositoryProtocol +from airweave.domains.temporal.protocols import ( + TemporalScheduleServiceProtocol, + TemporalWorkflowServiceProtocol, +) +from airweave.models.collection import Collection +from airweave.models.connection import Connection +from airweave.models.owned_provisioning import OwnedProvisioning +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync +from airweave.models.sync_job import SyncJob +from airweave.schemas.sync_job import SyncJobCreate + +EXECUTION_TIMEOUT_SECONDS = 20 + + +class OwnedProvisioningService: + """Retries repair one desired relationship; there is no independent job queue.""" + + def __init__( + self, + store: ProvisioningStore, + lifecycle: SourceLifecycleServiceProtocol, + jobs: SyncJobRepositoryProtocol, + syncs: SyncRepositoryProtocol, + schedules: TemporalScheduleServiceProtocol, + workflows: TemporalWorkflowServiceProtocol, + ): + """Use existing source verification, job admission and Temporal services.""" + self.store, self.lifecycle = store, lifecycle + self.jobs, self.syncs = jobs, syncs + self.schedules, self.workflows = schedules, workflows + + async def ensure( + self, db: AsyncSession, ctx: ApiContext, account: UUID, request: EnsureSource + ) -> ProvisionedSource: + """Persist before verification/execution; failures are retried with the same request.""" + await self.store.ensure(db, ctx, account, request) + return await self.reconcile(db, ctx, account, request.generation) + + async def get(self, db: AsyncSession, ctx: ApiContext, account: UUID) -> ProvisionedSource: + """Read only current committed relationship; never infer completion from IDs.""" + row = await db.scalar( + select(OwnedProvisioning) + .where( + OwnedProvisioning.organization_id == ctx.organization.id, + OwnedProvisioning.client_namespace == "almanac", + OwnedProvisioning.account_id == account, + ) + .execution_options(populate_existing=True) + ) + if row is None: + raise HTTPException(status_code=404, detail="Owned account source not found") + source = ( + await db.get(SourceConnection, row.source_connection_id) + if row.source_connection_id + else None + ) + identity, user = ( + native_principal(source.short_name, source.config_fields) if source else (None, None) + ) + return ProvisionedSource( + account_id=row.account_id, + organization_id=row.organization_id, + generation=row.generation, + observed_generation=row.observed_generation, + state=( + "pending" + if row.observed_generation != row.generation + else "ready" + if row.desired_state == "active" + else row.desired_state + ), + source_connection_id=row.source_connection_id, + sync_id=row.sync_id, + expected_identity=identity, + expected_user_identity=user, + ) + + async def reconcile( + self, db: AsyncSession, ctx: ApiContext, account: UUID, generation: int + ) -> ProvisionedSource: + """Provider requests outside locks; late success must pass generation CAS.""" + async with UnitOfWork(db): + row = await locked_intent(db, ctx.organization.id, account) + self._current(row, generation) + done = row.observed_generation == generation + source_id, sync_id = row.source_connection_id, row.sync_id + active = row.desired_state == "active" + needs_verification = active and row.verified_at is None + if done: + return await self.get(db, ctx, account) + if needs_verification: + # create performs the connector's native principal validation, not + # merely connected-account metadata validation in Composio. + verified_source = await self.lifecycle.create(db, source_id, ctx) + await verified_source.http_client.aclose() + await db.commit() + async with UnitOfWork(db) as uow: + row = await locked_intent(db, ctx.organization.id, account) + self._current(row, generation) + sync = await db.scalar( + select(Sync) + .where(Sync.id == sync_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + source = await db.get(SourceConnection, source_id) + sync.provisioning_ready_generation = generation + sync.status = "active" + source.is_authenticated = True + row.verified_at = utc_now_naive() + # Admission reloads Sync with populate_existing; production disables + # autoflush. Keep readiness and admission in this same transaction. + await db.flush() + if row.initial_job_id is None: + job = await self.jobs.create(db, SyncJobCreate(sync_id=sync.id), ctx, uow=uow) + await db.flush() + row.initial_job_id = job.id + try: + async with asyncio.timeout(EXECUTION_TIMEOUT_SECONDS): + await self._execution(db, ctx, account, generation) + except TimeoutError: + raise HTTPException( + status_code=503, detail="Owned capture setup is still pending" + ) from None + return await self.get(db, ctx, account) + + @staticmethod + def _current(row: OwnedProvisioning, generation: int) -> None: + if row.generation != generation: + raise HTTPException(status_code=409, detail="Account generation was superseded") + + async def _execution( + self, db: AsyncSession, ctx: ApiContext, account: UUID, generation: int + ) -> None: + # Only bounded Temporal RPCs occur under this relationship lock. This + # serializes older schedule creation against newer disconnect cleanup. + async with UnitOfWork(db) as uow: + row = await locked_intent(db, ctx.organization.id, account) + self._current(row, generation) + if row.observed_generation == generation: + return + for job_id in row.cancellation_job_ids: + result = await self.workflows.cancel_sync_job_workflow(job_id, ctx) + if not result["success"]: + raise HTTPException( + status_code=503, detail="Owned capture cancellation is pending" + ) + row.cancellation_job_ids = [] + if row.desired_state != "active": + if row.sync_id is not None: + await self.schedules.delete_all_schedules_for_sync( + row.sync_id, db, ctx, uow=uow, strict=True + ) + else: + await self._start(db, ctx, row, uow) + row.observed_generation = generation + + async def _start( + self, db: AsyncSession, ctx: ApiContext, row: OwnedProvisioning, uow: UnitOfWork + ) -> None: + spec = EnsureSource.model_validate(row.request_payload).source + source = await db.get(SourceConnection, row.source_connection_id) + # A prior RPC may have succeeded before its DB link committed. Remove + # deterministic handles even when temporal_schedule_id is absent. + await self.schedules.delete_all_schedules_for_sync( + row.sync_id, db, ctx, uow=uow, strict=True + ) + await self.schedules.create_or_update_schedule( + row.sync_id, + spec.cron, + db, + ctx, + uow, + collection_readable_id=source.readable_collection_id, + connection_id=source.connection_id, + ) + job = await db.get(SyncJob, row.initial_job_id) + if job.status not in ("pending", "running"): + return # A lost reply may be retried after the initial run already finished. + sync = await self.syncs.get(db, row.sync_id, ctx) + collection = await db.scalar( + select(Collection).where( + Collection.organization_id == ctx.organization.id, + Collection.readable_id == source.readable_collection_id, + ) + ) + connection = await db.get(Connection, source.connection_id) + try: + await self.workflows.run_source_connection_workflow( + sync=sync, + sync_job=schemas.SyncJob.model_validate(job, from_attributes=True), + collection=schemas.CollectionRecord.model_validate( + collection, from_attributes=True + ), + connection=schemas.Connection.model_validate(connection, from_attributes=True), + ctx=ctx, + ) + except WorkflowAlreadyStartedError: + pass # Same durable job ID is the exact same execution, not a new attempt. diff --git a/backend/airweave/domains/owned_provisioning/store.py b/backend/airweave/domains/owned_provisioning/store.py new file mode 100644 index 0000000000..6becbd27a5 --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/store.py @@ -0,0 +1,248 @@ +"""Commit desired generation and source configuration before remote execution.""" + +import hashlib +import json +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api.context import ApiContext +from airweave.core.datetime_utils import utc_now_naive +from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.owned_provisioning.models import EnsureSource, native_principal +from airweave.domains.source_connections.protocols import SourceConnectionCreateServiceProtocol +from airweave.domains.sources.protocols import SourceValidationServiceProtocol +from airweave.models.connection import Connection +from airweave.models.owned_provisioning import OwnedProvisioning +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync +from airweave.models.sync_job import SyncJob +from airweave.platform.configs.config import StripeConfig +from airweave.schemas.source_connection import ( + AuthProviderAuthentication, + ScheduleConfig, + SourceConnectionCreate, +) + + +async def locked_intent(db: AsyncSession, organization: UUID, account: UUID) -> OwnedProvisioning: + """Every generation/execution mutation serializes on this one relationship.""" + row = await db.scalar( + select(OwnedProvisioning) + .where( + OwnedProvisioning.organization_id == organization, + OwnedProvisioning.client_namespace == "almanac", + OwnedProvisioning.account_id == account, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None: + raise HTTPException(status_code=404, detail="Owned account source not found") + return row + + +class ProvisioningStore: + """Source domain creation participates in our transaction; it cannot commit early.""" + + def __init__( + self, + create: SourceConnectionCreateServiceProtocol, + validation: SourceValidationServiceProtocol, + ): + """Reuse transactional source creation and source-specific validation.""" + self.create = create + self.validation = validation + + async def ensure( + self, db: AsyncSession, ctx: ApiContext, account: UUID, request: EnsureSource + ) -> None: + """Lost replies replay one source; lower generations cannot overwrite it.""" + payload = request.model_dump(mode="json") + fingerprint = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + async with UnitOfWork(db) as uow: + now = utc_now_naive() + await db.execute( + insert(OwnedProvisioning) + .values( + id=uuid4(), + organization_id=ctx.organization.id, + client_namespace="almanac", + account_id=account, + generation=request.generation, + request_hash=fingerprint, + request_payload=payload, + desired_state=request.state, + observed_generation=0, + cancellation_job_ids=[], + created_at=now, + modified_at=now, + ) + .on_conflict_do_nothing(constraint="uq_owned_provisioning_account") + ) + row = await locked_intent(db, ctx.organization.id, account) + if request.generation < row.generation: + raise HTTPException(status_code=409, detail="Account generation was superseded") + if request.generation == row.generation and row.request_hash != fingerprint: + raise HTTPException( + status_code=409, detail="Generation already names another request" + ) + if request.generation == row.generation and ( + row.sync_id is not None or row.desired_state != "active" + ): + return + if row.desired_state == "disconnected" and request.state != "disconnected": + raise HTTPException( + status_code=409, detail="Connect a new account after disconnect" + ) + if request.state == "active" and row.source_connection_id is not None: + source = await db.scalar( + select(SourceConnection) + .where( + SourceConnection.id == row.source_connection_id, + SourceConnection.organization_id == ctx.organization.id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + source.short_name, + native_principal(source.short_name, source.config_fields), + source.readable_collection_id, + ) != ( + request.source.provider, + (request.source.expected_identity, request.source.expected_user_identity), + request.source.collection, + ): + raise HTTPException( + status_code=409, detail="Reconnect changes original account identity" + ) + if request.source is not None: + await self._configure(db, uow, ctx, row, request) + if row.sync_id is not None: + sync = await db.scalar( + select(Sync) + .where(Sync.id == row.sync_id, Sync.organization_id == ctx.organization.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + sync.provisioning_generation = request.generation + sync.status = "paused" + sync.writer_epoch += 1 + jobs = ( + await db.scalars( + select(SyncJob) + .where( + SyncJob.sync_id == sync.id, + SyncJob.status.in_(("pending", "running", "cancelling")), + ) + .with_for_update() + ) + ).all() + row.cancellation_job_ids = list( + dict.fromkeys([*row.cancellation_job_ids, *(str(job.id) for job in jobs)]) + ) + for job in jobs: + job.status = "cancelled" + source = await db.get(SourceConnection, row.source_connection_id) + source.is_authenticated = False + row.generation = request.generation + row.request_payload = payload + row.request_hash = fingerprint + row.desired_state = request.state + row.initial_job_id = None + row.verified_at = None + await db.flush() + + async def _configure( + self, + db: AsyncSession, + uow: UnitOfWork, + ctx: ApiContext, + row: OwnedProvisioning, + request: EnsureSource, + ) -> None: + spec = request.source + auth = await db.scalar( + select(Connection).where( + Connection.organization_id == ctx.organization.id, + Connection.readable_id == spec.auth_provider, + Connection.short_name == "composio", + ) + ) + if auth is None: + raise HTTPException( + status_code=400, + detail="Owned capture requires this organization's Composio connection", + ) + config = self.validation.validate_config(spec.provider, spec.source_config(), ctx) + if row.source_connection_id is None: + created = await self.create.create_deferred( + db, + ctx=ctx, + uow=uow, + obj_in=SourceConnectionCreate( + name="Almanac " + spec.provider, + short_name=spec.provider, + readable_collection_id=spec.collection, + config=config, + schedule=ScheduleConfig(cron=spec.cron), + sync_immediately=False, + authentication=AuthProviderAuthentication( + provider_readable_id=spec.auth_provider, provider_config=spec.auth_config() + ), + ), + ) + if created.sync_id is None: + raise ValueError("Owned source creation must create its durable sync") + row.source_connection_id = created.id + row.sync_id = created.sync_id + else: + source = await db.scalar( + select(SourceConnection) + .where( + SourceConnection.id == row.source_connection_id, + SourceConnection.organization_id == ctx.organization.id, + ) + .with_for_update() + ) + if spec.provider == "stripe" and ( + StripeConfig.model_validate(source.config_fields or {}).original_capture + != StripeConfig.model_validate(config).original_capture + ): + raise HTTPException( + status_code=409, + detail="Reconnect changes Stripe account mode, context or API version; " + "connect a new account", + ) + if spec.provider == "github": + from airweave.platform.configs.config import GitHubConfig + + if GitHubConfig.model_validate(source.config_fields) != GitHubConfig.model_validate( + config + ): + raise HTTPException( + status_code=409, + detail="Reconnect changes GitHub repository selection or capture options; " + "connect a new account", + ) + if spec.provider == "linear": + from airweave.platform.configs.config import LinearConfig + + previous_teams = set(LinearConfig.model_validate(source.config_fields).team_ids) + current_teams = set(LinearConfig.model_validate(config).team_ids) + if previous_teams != current_teams: + raise HTTPException( + status_code=409, + detail="Reconnect changes selected Linear teams; connect a new account", + ) + source.config_fields = config + source.readable_auth_provider_id = spec.auth_provider + source.auth_provider_config = spec.auth_config() + sync = await db.get(Sync, row.sync_id) + sync.cron_schedule = spec.cron diff --git a/backend/airweave/domains/owned_provisioning/tests/conftest.py b/backend/airweave/domains/owned_provisioning/tests/conftest.py new file mode 100644 index 0000000000..2916906a10 --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/tests/conftest.py @@ -0,0 +1,14 @@ +"""Use isolated PostgreSQL with the production session autoflush policy.""" + +import pytest + +from airweave.domains.entities.canonical.tests.conftest import ( + database as canonical_database, # noqa: F401 +) + + +@pytest.fixture +async def database(canonical_database): # noqa: F811 - pytest injects the imported fixture + """Keep implicit flushes from masking production transaction ordering.""" + canonical_database.configure(autoflush=False) + return canonical_database diff --git a/backend/airweave/domains/owned_provisioning/tests/test_provisioning.py b/backend/airweave/domains/owned_provisioning/tests/test_provisioning.py new file mode 100644 index 0000000000..160e2fa6e2 --- /dev/null +++ b/backend/airweave/domains/owned_provisioning/tests/test_provisioning.py @@ -0,0 +1,999 @@ +"""Real SQL lifecycle and lost-reply recovery; no private provider or Temporal calls.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from sqlalchemy import func, select + +from airweave import schemas +from airweave.core.shared_models import AuthMethod, IntegrationType +from airweave.domains.collections.repository import CollectionRepository +from airweave.domains.connections.repository import ConnectionRepository +from airweave.domains.owned_provisioning.models import EnsureSource, ManagedSource, native_principal +from airweave.domains.owned_provisioning.service import OwnedProvisioningService +from airweave.domains.owned_provisioning.store import ProvisioningStore +from airweave.domains.source_connections.repository import SourceConnectionRepository +from airweave.domains.source_connections.tests.test_create import _ctx, _entry, _service +from airweave.domains.syncs.jobs.repository import SyncJobRepository +from airweave.domains.syncs.repository import SyncRepository +from airweave.domains.syncs.service import SyncService +from airweave.models import Collection, Connection, Organization, SourceConnection, Sync, SyncJob +from airweave.models.owned_provisioning import OwnedProvisioning +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def setup(database): + ctx = _ctx() + ctx.auth_method = AuthMethod.API_KEY + metadata_id = uuid4() + async with database() as db: + db.add(Organization(id=ctx.organization.id, name="Synthetic provisioning")) + db.add( + VectorDbDeploymentMetadata( + id=metadata_id, + dense_embedder="test", + sparse_embedder="test", + embedding_dimensions=3, + ) + ) + await db.flush() + db.add( + Collection( + name="Owned", + readable_id="owned", + organization_id=ctx.organization.id, + vector_db_deployment_metadata_id=metadata_id, + ) + ) + db.add( + Connection( + name="Composio", + readable_id="composio", + short_name="composio", + organization_id=ctx.organization.id, + integration_type=IntegrationType.AUTH_PROVIDER, + ) + ) + await db.commit() + + lifecycle = SimpleNamespace( + create=AsyncMock( + return_value=SimpleNamespace(http_client=SimpleNamespace(aclose=AsyncMock())) + ) + ) + schedules = SimpleNamespace( + create_or_update_schedule=AsyncMock(), delete_all_schedules_for_sync=AsyncMock() + ) + workflows = SimpleNamespace( + run_source_connection_workflow=AsyncMock(), + cancel_sync_job_workflow=AsyncMock(return_value={"success": True, "workflow_found": True}), + ) + entry = _entry() + entry.short_name = "gmail" + creator = _service(entry) + creator._sc_repo = SourceConnectionRepository(creator._source_registry) + creator._collection_repo = CollectionRepository(creator._source_registry, creator._sc_repo) + creator._connection_repo = ConnectionRepository() + creator._source_validation.seed_config_result( + "gmail", {"expected_mailbox": "owner@example.test"} + ) + syncs = SyncRepository() + jobs = SyncJobRepository() + creator._sync_service = SyncService( + syncs, jobs, Mock(), Mock(), Mock(), workflows, schedules, Mock() + ) + creator._sync_service.resolve_destination_ids = AsyncMock(return_value=[]) + creator._response_builder.build_response = AsyncMock( + side_effect=lambda db, source, ctx: SimpleNamespace(id=source.id, sync_id=source.sync_id) + ) + service = OwnedProvisioningService( + ProvisioningStore( + creator, SimpleNamespace(validate_config=Mock(side_effect=lambda p, c, ctx: c)) + ), + lifecycle, + jobs, + syncs, + schedules, + workflows, + ) + request = EnsureSource( + generation=1, + state="active", + source=ManagedSource( + provider="gmail", + expected_identity="owner@example.test", + collection="owned", + auth_provider="composio", + connected_account_id="ca_first", + auth_config_id="ac_test", + user_id="owner", + cron="0 * * * *", + ), + ) + return ctx, service, request, uuid4(), lifecycle, schedules, workflows + + +async def test_lost_reply_and_execution_failure_reuse_source_and_job(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + schedules.create_or_update_schedule.side_effect = RuntimeError("Temporal unavailable") + async with database() as db: + with pytest.raises(RuntimeError, match="Temporal unavailable"): + await service.ensure(db, ctx, account, request) + async with database() as db: + pending = await service.get(db, ctx, account) + assert pending.state == "pending" + row = await db.scalar( + select(OwnedProvisioning).where(OwnedProvisioning.account_id == account) + ) + first_job = row.initial_job_id + schedules.create_or_update_schedule.side_effect = None + async with database() as db: + ready = await service.ensure(db, ctx, account, request) + async with database() as db: + replay = await service.ensure(db, ctx, account, request) + assert ready == replay and ready.state == "ready" + assert ready.sync_id == pending.sync_id + assert ( + await db.scalar( + select(func.count()) + .select_from(SourceConnection) + .where(SourceConnection.organization_id == ctx.organization.id) + ) + == 1 + ) + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == ready.sync_id) + ) + == 1 + ) + assert (await db.get(SyncJob, first_job)).provisioning_generation == 1 + with pytest.raises(HTTPException) as conflict: + await service.ensure( + db, + ctx, + account, + request.model_copy( + update={ + "source": request.source.model_copy( + update={"connected_account_id": "ca_other"} + ) + } + ), + ) + assert conflict.value.status_code == 409 + lifecycle.create.assert_awaited_once() + workflows.run_source_connection_workflow.assert_awaited_once() + # Reconciliation always repairs orphan deterministic schedules, even with no DB link. + assert schedules.delete_all_schedules_for_sync.await_count == 2 + + +async def test_disconnect_wins_over_inflight_verification_and_blocks_admission(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + started, release = asyncio.Event(), asyncio.Event() + resource = SimpleNamespace(http_client=SimpleNamespace(aclose=AsyncMock())) + + async def verify(*args): + started.set() + await release.wait() + return resource + + lifecycle.create.side_effect = verify + + async def connect(): + async with database() as db: + return await service.ensure(db, ctx, account, request) + + task = asyncio.create_task(connect()) + await started.wait() + async with database() as db: + pending = await service.get(db, ctx, account) + with pytest.raises(HTTPException) as blocked: + await service.jobs.create(db, schemas.SyncJobCreate(sync_id=pending.sync_id), ctx) + assert blocked.value.status_code == 409 + async with database() as db: + disconnected = await service.ensure( + db, ctx, account, EnsureSource(generation=2, state="disconnected") + ) + assert disconnected.state == "disconnected" + release.set() + with pytest.raises(HTTPException) as stale: + await task + assert stale.value.status_code == 409 + resource.http_client.aclose.assert_awaited_once() + workflows.run_source_connection_workflow.assert_not_awaited() + async with database() as db: + with pytest.raises(HTTPException): + await service.ensure(db, ctx, account, request) + assert (await db.get(Sync, pending.sync_id)).provisioning_ready_generation == 0 + + +async def test_rotation_timeout_retains_cleanup_and_original_source(database, setup, monkeypatch): + ctx, service, request, account, lifecycle, schedules, workflows = setup + async with database() as db: + first = await service.ensure(db, ctx, account, request) + original_job = await db.scalar(select(SyncJob.id).where(SyncJob.sync_id == first.sync_id)) + rotated = request.model_copy( + update={ + "generation": 2, + "source": request.source.model_copy(update={"connected_account_id": "ca_new"}), + } + ) + + async def stalled(*args): + await asyncio.Event().wait() + + workflows.cancel_sync_job_workflow.side_effect = stalled + monkeypatch.setattr( + "airweave.domains.owned_provisioning.service.EXECUTION_TIMEOUT_SECONDS", 0.02 + ) + async with database() as db: + with pytest.raises(HTTPException) as timeout: + await service.ensure(db, ctx, account, rotated) + assert timeout.value.status_code == 503 + async with database() as db: + row = await db.scalar( + select(OwnedProvisioning).where(OwnedProvisioning.account_id == account) + ) + assert row.cancellation_job_ids == [str(original_job)] + assert row.generation == 2 and row.observed_generation == 1 + assert ( + row.sync_id == first.sync_id and row.source_connection_id == first.source_connection_id + ) + workflows.cancel_sync_job_workflow.side_effect = None + monkeypatch.setattr("airweave.domains.owned_provisioning.service.EXECUTION_TIMEOUT_SECONDS", 20) + async with database() as db: + second = await service.ensure(db, ctx, account, rotated) + assert second.state == "ready" and second.sync_id == first.sync_id + assert ( + await db.scalar( + select(func.count()).select_from(SyncJob).where(SyncJob.sync_id == first.sync_id) + ) + == 2 + ) + other = _ctx() + with pytest.raises(HTTPException) as denied: + await service.get(db, other, account) + assert denied.value.status_code == 404 + + +async def test_http_boundary_and_legacy_edit_cannot_bypass_generation(database, setup): + import httpx + from fastapi import FastAPI + + from airweave.api import deps + from airweave.api.v1.endpoints.owned_provisioning import router + from airweave.db.session import get_db + from airweave.domains.source_connections.tests.test_delete import ( + _build_service as delete_service, + ) + from airweave.domains.source_connections.tests.test_update import ( + _build_service as update_service, + ) + from airweave.schemas.source_connection import SourceConnectionUpdate + + ctx, service, request, account, lifecycle, schedules, workflows = setup + app = FastAPI() + app.include_router(router, prefix="/owned-sources") + + async def session(): + async with database() as db: + yield db + + app.dependency_overrides[get_db] = session + app.dependency_overrides[deps.get_context] = lambda: ctx + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace( + owned_provisioning=service + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as http: + ctx.auth_method = AuthMethod.SYSTEM + denied = await http.put(f"/owned-sources/{account}", json=request.model_dump(mode="json")) + assert denied.status_code == 403 + lifecycle.create.assert_not_awaited() + ctx.auth_method = AuthMethod.API_KEY + connected = await http.put( + f"/owned-sources/{account}", json=request.model_dump(mode="json") + ) + assert connected.status_code == 200, connected.text + source_id = connected.json()["source_connection_id"] + from uuid import UUID + + source_id = UUID(source_id) + repo = SourceConnectionRepository(service.store.create._source_registry) + async with database() as db: + with pytest.raises(HTTPException) as guarded: + await update_service(sc_repo=repo).update( + db, + id=source_id, + obj_in=SourceConnectionUpdate( + config={"expected_mailbox": "someone-else@example.test"} + ), + ctx=ctx, + ) + assert guarded.value.status_code == 409 + async with database() as db: + with pytest.raises(HTTPException) as guarded: + await delete_service(sc_repo=repo).delete(db, id=source_id, ctx=ctx) + assert guarded.value.status_code == 409 + + +async def test_creation_failure_rolls_back_relationship_source_and_sync(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + response = service.store.create._response_builder.build_response + original = response.side_effect + response.side_effect = RuntimeError("creation interrupted before commit") + async with database() as db: + with pytest.raises(RuntimeError, match="creation interrupted"): + await service.ensure(db, ctx, account, request) + async with database() as db: + for model in (OwnedProvisioning, SourceConnection, Sync, SyncJob): + assert ( + await db.scalar( + select(func.count()) + .select_from(model) + .where(model.organization_id == ctx.organization.id) + ) + == 0 + ) + lifecycle.create.assert_not_awaited() + schedules.create_or_update_schedule.assert_not_awaited() + response.side_effect = original + async with database() as db: + ready = await service.ensure(db, ctx, account, request) + assert ready.state == "ready" + + +async def test_pause_resume_preserves_identity_source_and_cleanup(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + async with database() as db: + first = await service.ensure(db, ctx, account, request) + async with database() as db: + paused = await service.ensure(db, ctx, account, EnsureSource(generation=2, state="paused")) + assert paused.state == "paused" + assert paused.expected_identity == request.source.expected_identity + assert (paused.source_connection_id, paused.sync_id) == ( + first.source_connection_id, + first.sync_id, + ) + sync = await db.get(Sync, first.sync_id) + assert sync.status == "paused" and sync.provisioning_generation == 2 + with pytest.raises(HTTPException): + await service.jobs.create(db, schemas.SyncJobCreate(sync_id=first.sync_id), ctx) + workflows.cancel_sync_job_workflow.assert_awaited_once() + async with database() as db: + wrong_source = request.source.model_copy(update={"expected_identity": "other@example.test"}) + wrong = request.model_copy(update={"generation": 3, "source": wrong_source}) + with pytest.raises(HTTPException, match="Reconnect changes original account identity"): + await service.ensure(db, ctx, account, wrong) + async with database() as db: + resumed = await service.ensure( + db, ctx, account, request.model_copy(update={"generation": 3}) + ) + assert resumed.state == "ready" + assert (resumed.source_connection_id, resumed.sync_id) == ( + first.source_connection_id, + first.sync_id, + ) + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert lifecycle.create.await_count == 2 + async with database() as db: + stopped = await service.ensure( + db, ctx, account, EnsureSource(generation=4, state="disconnected") + ) + assert stopped.expected_identity == request.source.expected_identity + async with database() as db: + with pytest.raises(HTTPException, match="Connect a new account after disconnect"): + await service.ensure(db, ctx, account, request.model_copy(update={"generation": 5})) + + +async def test_initial_pause_can_activate_but_initial_disconnect_is_terminal(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + async with database() as db: + paused = await service.ensure(db, ctx, account, EnsureSource(generation=1, state="paused")) + assert paused.state == "paused" and paused.expected_identity is None + assert paused.source_connection_id is None and paused.sync_id is None + async with database() as db: + active = await service.ensure( + db, ctx, account, request.model_copy(update={"generation": 2}) + ) + assert active.state == "ready" + other = uuid4() + async with database() as db: + await service.ensure(db, ctx, other, EnsureSource(generation=1, state="disconnected")) + async with database() as db: + with pytest.raises(HTTPException, match="Connect a new account after disconnect"): + await service.ensure(db, ctx, other, request.model_copy(update={"generation": 2})) + + +async def test_slack_workspace_user_survives_pause_and_rejects_different_member(database, setup): + ctx, service, request, account, lifecycle, schedules, workflows = setup + creator = service.store.create + creator._source_registry.get.return_value.short_name = "slack" + creator._source_validation.seed_config_result( + "slack", {"expected_team_id": "T1", "expected_user_id": "U1"} + ) + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": "slack", + "expected_identity": "T1", + "expected_user_identity": "U1", + "config": {"expected_team_id": "wrong", "expected_user_id": "wrong"}, + } + ) + request = request.model_copy(update={"source": spec}) + assert spec.source_config() == {"expected_team_id": "T1", "expected_user_id": "U1"} + async with database() as db: + first = await service.ensure(db, ctx, account, request) + assert (first.expected_identity, first.expected_user_identity) == ("T1", "U1") + async with database() as db: + paused = await service.ensure(db, ctx, account, EnsureSource(generation=2, state="paused")) + assert (paused.expected_identity, paused.expected_user_identity) == ("T1", "U1") + async with database() as db: + wrong = request.model_copy( + update={ + "generation": 3, + "source": spec.model_copy(update={"expected_user_identity": "U2"}), + } + ) + with pytest.raises(HTTPException, match="Reconnect changes original account identity"): + await service.ensure(db, ctx, account, wrong) + async with database() as db: + resumed = await service.ensure( + db, ctx, account, request.model_copy(update={"generation": 3}) + ) + assert resumed.source_connection_id == first.source_connection_id + assert (resumed.expected_identity, resumed.expected_user_identity) == ("T1", "U1") + + +@pytest.mark.parametrize("provider", ["outlook_mail", "outlook_calendar"]) +async def test_outlook_fresh_source_retry_and_principal_cannot_downgrade_owned_capture( + database, setup, provider +): + ctx, service, request, account, lifecycle, schedules, workflows = setup + creator = service.store.create + creator._source_registry.get.return_value.short_name = provider + expected_config = {"expected_principal_id": "native-owner", "capture_originals": True} + creator._source_validation.seed_config_result(provider, expected_config) + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": provider, + "expected_identity": "native-owner", + "config": {"expected_principal_id": "wrong-owner", "capture_originals": False}, + } + ) + request = request.model_copy(update={"source": spec}) + assert spec.source_config() == expected_config + assert native_principal(provider, spec.source_config()) == ("native-owner", None) + with pytest.raises(ValueError, match="must capture originals"): + native_principal(provider, {"expected_principal_id": "native-owner"}) + with pytest.raises(ValueError): + native_principal(provider, {"capture_originals": True}) + + async with database() as db: + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 0 + first = await service.ensure(db, ctx, account, request) + assert first.state == "ready" and first.expected_identity == "native-owner" + source = await db.get(SourceConnection, first.source_connection_id) + assert source.short_name == provider and source.config_fields == expected_config + sync = await db.get(Sync, first.sync_id) + original_epoch = sync.writer_epoch + assert sync.provisioning_generation == sync.provisioning_ready_generation == 1 + assert sync.index_pipeline_version == 2 + async with database() as db: + replay = await service.ensure(db, ctx, account, request) + assert replay == first + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert await db.scalar(select(func.count()).select_from(Sync)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + assert (await db.get(Sync, first.sync_id)).writer_epoch == original_epoch + async with database() as db: + wrong = request.model_copy( + update={ + "generation": 2, + "source": spec.model_copy(update={"expected_identity": "other-native-owner"}), + } + ) + with pytest.raises(HTTPException, match="Reconnect changes original account identity"): + await service.ensure(db, ctx, account, wrong) + async with database() as db: + current = await service.get(db, ctx, account) + assert current == first + source = await db.get(SourceConnection, first.source_connection_id) + assert source.config_fields == expected_config + assert (await db.get(Sync, first.sync_id)).writer_epoch == original_epoch + lifecycle.create.assert_awaited_once() + workflows.run_source_connection_workflow.assert_awaited_once() + + +async def test_verified_source_job_admission_without_autoflush(database, setup): + """Production sessions must persist readiness before admission reloads the Sync.""" + ctx, service, request, account, lifecycle, schedules, workflows = setup + async with database(autoflush=False) as db: + result = await service.ensure(db, ctx, account, request) + assert result.state == "ready" + async with database(autoflush=False) as db: + sync = await db.get(Sync, result.sync_id) + source = await db.get(SourceConnection, result.source_connection_id) + row = await db.scalar( + select(OwnedProvisioning).where(OwnedProvisioning.account_id == account) + ) + job = await db.get(SyncJob, row.initial_job_id) + assert sync.provisioning_ready_generation == request.generation + assert sync.status == "active" and source.is_authenticated + assert row.verified_at is not None + assert job.provisioning_generation == request.generation + assert (await service.ensure(db, ctx, account, request)) == result + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + lifecycle.create.assert_awaited_once() + workflows.run_source_connection_workflow.assert_awaited_once() + + +async def test_job_admission_failure_rolls_back_verified_readiness(database, setup, monkeypatch): + """Flushed verification and a real admitted job remain in the same transaction.""" + ctx, service, request, account, lifecycle, schedules, workflows = setup + create_job = service.jobs.create + + async def fail_after_admission(db, obj_in, ctx, uow=None): + job = await create_job(db, obj_in, ctx, uow=uow) + await db.flush() + assert job.provisioning_generation == request.generation + raise RuntimeError("Synthetic failure after real job admission") + + monkeypatch.setattr(service.jobs, "create", fail_after_admission) + async with database(autoflush=False) as db: + with pytest.raises(RuntimeError, match="Synthetic failure after real job admission"): + await service.ensure(db, ctx, account, request) + async with database(autoflush=False) as db: + row = await db.scalar( + select(OwnedProvisioning).where(OwnedProvisioning.account_id == account) + ) + sync = await db.get(Sync, row.sync_id) + source = await db.get(SourceConnection, row.source_connection_id) + assert row.verified_at is None and row.initial_job_id is None + assert sync.provisioning_ready_generation != request.generation + assert sync.status != "active" and not source.is_authenticated + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 0 + assert (await service.get(db, ctx, account)).state == "pending" + schedules.create_or_update_schedule.assert_not_awaited() + workflows.run_source_connection_workflow.assert_not_awaited() + monkeypatch.setattr(service.jobs, "create", create_job) + async with database(autoflush=False) as db: + assert (await service.ensure(db, ctx, account, request)).state == "ready" + + +async def test_stripe_provisioning_retry_and_reconnect_preserve_account_mode_and_version( + database, setup +): + ctx, service, request, account, lifecycle, schedules, workflows = setup + creator = service.store.create + creator._source_registry.get.return_value.short_name = "stripe" + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": "stripe", + "expected_identity": "acct_selected", + "config": { + "expected_account_id": "acct_untrusted", + "livemode": False, + "api_version": "2025-06-30.basil", + }, + } + ) + expected_config = spec.source_config() + creator._source_validation.seed_config_result("stripe", expected_config) + assert native_principal("stripe", expected_config) == ("acct_selected", None) + request = request.model_copy(update={"source": spec}) + async with database() as db: + first = await service.ensure(db, ctx, account, request) + sync = await db.get(Sync, first.sync_id) + assert sync.index_pipeline_version == 2 + original_epoch = sync.writer_epoch + async with database() as db: + assert await service.ensure(db, ctx, account, request) == first + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert (await db.get(Sync, first.sync_id)).writer_epoch == original_epoch + for change in ( + {"livemode": True}, + {"api_version": "2026-01-28.clover"}, + {"connected_account_id": "acct_selected"}, + ): + changed = ManagedSource.model_validate( + { + **spec.model_dump(), + "config": {**spec.config, **change}, + } + ) + async with database() as db: + with pytest.raises(HTTPException, match="Reconnect changes Stripe"): + await service.ensure( + db, + ctx, + account, + request.model_copy( + update={ + "generation": 2, + "source": changed, + } + ), + ) + async with database() as db: + assert await service.get(db, ctx, account) == first + assert ( + await db.get(SourceConnection, first.source_connection_id) + ).config_fields == expected_config + assert (await db.get(Sync, first.sync_id)).writer_epoch == original_epoch + # New broker credentials may reconnect the same native identity/configuration. + async with database() as db: + reconnected = await service.ensure( + db, + ctx, + account, + request.model_copy( + update={ + "generation": 2, + "source": spec.model_copy(update={"connected_account_id": "ca_reconnected"}), + } + ), + ) + assert reconnected.source_connection_id == first.source_connection_id + assert reconnected.sync_id == first.sync_id and reconnected.generation == 2 + source = await db.get(SourceConnection, first.source_connection_id) + assert source.auth_provider_config["account_id"] == "ca_reconnected" + assert source.config_fields == expected_config + + +@pytest.mark.parametrize( + "config", + [ + {}, + {"livemode": "false", "api_version": "2025-06-30.basil"}, + { + "livemode": False, + "api_version": "2025-06-30.basil", + "connected_account_id": "acct_other", + }, + ], +) +def test_stripe_provisioning_requires_explicit_typed_mode_and_version(config): + with pytest.raises(ValueError): + ManagedSource( + provider="stripe", + expected_identity="acct_selected", + collection="owned", + auth_provider="composio", + connected_account_id="ca_selected", + auth_config_id="ac_selected", + user_id="owner", + cron="0 * * * *", + config=config, + ) + + +@pytest.mark.parametrize("provider", ["linear", "attio"]) +async def test_workspace_provisioning_retry_reconnect_and_immutable_scope( + database, setup, provider +): + ctx, service, request, account, lifecycle, schedules, workflows = setup + workspace, team1, team2 = uuid4(), uuid4(), uuid4() + config = {"workspace_id": str(uuid4())} + if provider == "linear": + config["team_ids"] = [str(team2), str(team1)] + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": provider, + "expected_identity": workspace.hex.upper(), + "config": config, + } + ) + assert spec.expected_identity == str(workspace) + expected = spec.source_config() + assert expected["workspace_id"] == str(workspace) + assert native_principal(provider, expected) == (str(workspace), None) + if provider == "linear": + assert expected["team_ids"] == sorted([str(team1), str(team2)]) + creator = service.store.create + creator._source_registry.get.return_value.short_name = provider + creator._source_validation.seed_config_result(provider, expected) + request = request.model_copy(update={"source": spec}) + async with database() as db: + first = await service.ensure(db, ctx, account, request) + epoch = (await db.get(Sync, first.sync_id)).writer_epoch + async with database() as db: + assert await service.ensure(db, ctx, account, request) == first + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + assert ( + await db.get(SourceConnection, first.source_connection_id) + ).config_fields == expected + changes = [{"expected_identity": str(uuid4())}] + if provider == "linear": + changes.append({"config": {"team_ids": [str(uuid4())]}}) + for change in changes: + invalid = ManagedSource.model_validate({**spec.model_dump(), **change}) + async with database() as db: + with pytest.raises(HTTPException, match="Reconnect changes"): + await service.ensure( + db, + ctx, + account, + request.model_copy( + update={ + "generation": 2, + "source": invalid, + } + ), + ) + async with database() as db: + unchanged = await service.get(db, ctx, account) + assert unchanged == first + assert unchanged.observed_generation == first.observed_generation == 1 + assert ( + await db.get(SourceConnection, first.source_connection_id) + ).config_fields == expected + assert (await db.get(Sync, first.sync_id)).writer_epoch == epoch + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + reconnect_config = dict(config) + if provider == "linear": + reconnect_config["team_ids"] = list(reversed(config["team_ids"])) + reconnect = request.model_copy( + update={ + "generation": 2, + "source": ManagedSource.model_validate( + { + **spec.model_dump(), + "connected_account_id": "ca_reconnected", + "config": reconnect_config, + } + ), + } + ) + async with database() as db: + second = await service.ensure(db, ctx, account, reconnect) + assert second.state == "ready" and second.generation == 2 + assert second.source_connection_id == first.source_connection_id + assert second.sync_id == first.sync_id + assert (await db.get(Sync, first.sync_id)).writer_epoch > epoch + original = await db.get(SourceConnection, first.source_connection_id) + assert original.config_fields == expected + assert original.auth_provider_config["account_id"] == "ca_reconnected" + async with database() as db: + assert await service.ensure(db, ctx, account, reconnect) == second + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 2 + assert lifecycle.create.await_count == 2 + + +@pytest.mark.parametrize( + "provider,identity,config", + [ + ("linear", str(uuid4()), {}), + ("linear", str(uuid4()), {"team_ids": []}), + ("linear", str(uuid4()), {"team_ids": ["not-a-uuid"]}), + ("linear", "not-a-uuid", {"team_ids": [str(uuid4())]}), + ("attio", "not-a-uuid", {}), + ], +) +def test_workspace_provisioning_rejects_invalid_intent(provider, identity, config): + with pytest.raises(ValueError): + ManagedSource( + provider=provider, + expected_identity=identity, + config=config, + collection="owned", + auth_provider="composio", + connected_account_id="ca_test", + auth_config_id="ac_test", + user_id="owner", + cron="0 * * * *", + ) + + +GITHUB_REPOSITORIES = [ + {"repository_id": 20, "owner_id": 30, "full_name": "team/two", "ref": "main"}, + {"repository_id": 10, "owner_id": 30, "full_name": "team/one"}, +] + + +async def test_github_provisioning_reconnect_preserves_principal_and_exact_scope(database, setup): + ctx, service, request, account, lifecycle, _, _ = setup + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": "github", + "expected_identity": "00042", + "config": {"expected_user_id": 999, "repositories": GITHUB_REPOSITORIES}, + } + ) + assert spec.expected_identity == "42" + config = spec.source_config() + assert config["expected_user_id"] == 42 + assert [row["repository_id"] for row in config["repositories"]] == [10, 20] + assert native_principal("github", config) == ("42", None) + creator = service.store.create + creator._source_registry.get.return_value.short_name = "github" + creator._source_validation.seed_config_result("github", config) + request = request.model_copy(update={"source": spec}) + async with database() as db: + first = await service.ensure(db, ctx, account, request) + epoch = (await db.get(Sync, first.sync_id)).writer_epoch + async with database() as db: + assert await service.ensure(db, ctx, account, request) == first + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + variants = [ + {"expected_identity": "43"}, + *( + { + "config": { + **spec.config, + "repositories": [{**GITHUB_REPOSITORIES[0], **change}, GITHUB_REPOSITORIES[1]], + } + } + for change in ( + {"repository_id": 21}, + {"owner_id": 31}, + {"ref": "other"}, + {"full_name": "team/renamed"}, + ) + ), + {"config": {**spec.config, "include_code": False}}, + {"config": {**spec.config, "include_conversations": False}}, + ] + for change in variants: + candidate = ManagedSource.model_validate({**spec.model_dump(), **change}) + async with database() as db: + with pytest.raises(HTTPException, match="Reconnect changes"): + await service.ensure( + db, + ctx, + account, + request.model_copy(update={"generation": 2, "source": candidate}), + ) + async with database() as db: + unchanged = await service.get(db, ctx, account) + assert unchanged == first and unchanged.observed_generation == 1 + assert (await db.get(Sync, first.sync_id)).writer_epoch == epoch + assert ( + await db.get(SourceConnection, first.source_connection_id) + ).config_fields == config + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + reconnected = ManagedSource.model_validate( + { + **spec.model_dump(), + "connected_account_id": "ca_reconnected", + "config": {**spec.config, "repositories": list(reversed(GITHUB_REPOSITORIES))}, + } + ) + retry = request.model_copy(update={"generation": 2, "source": reconnected}) + async with database() as db: + second = await service.ensure(db, ctx, account, retry) + assert second.source_connection_id == first.source_connection_id + assert second.sync_id == first.sync_id and second.observed_generation == 2 + assert (await db.get(Sync, first.sync_id)).writer_epoch > epoch + connection = await db.get(SourceConnection, first.source_connection_id) + assert connection.auth_provider_config["account_id"] == "ca_reconnected" + assert connection.config_fields == config + async with database() as db: + assert await service.ensure(db, ctx, account, retry) == second + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 2 + assert lifecycle.create.await_count == 2 + + +@pytest.mark.parametrize( + "identity,config", + [ + ("octocat", {"repositories": GITHUB_REPOSITORIES}), + ("0", {"repositories": GITHUB_REPOSITORIES}), + ("-42", {"repositories": GITHUB_REPOSITORIES}), + ("42", {"repositories": GITHUB_REPOSITORIES}), + ("42", {}), + ("42", {"repositories": []}), + ("42", {"repo_name": "team/one"}), + ], +) +def test_github_provisioning_rejects_invalid_principal_or_missing_selection(identity, config): + with pytest.raises(ValueError): + ManagedSource( + provider="github", + expected_identity=identity, + config=config, + collection="owned", + auth_provider="composio", + connected_account_id="ca_test", + auth_config_id="ac_test", + user_id="owner", + cron="0 * * * *", + ) + + +async def test_notion_reconnect_retains_workspace_and_bot_with_exact_retry(database, setup): + ctx, service, request, account, lifecycle, _, _ = setup + workspace, bot = uuid4(), uuid4() + spec = ManagedSource.model_validate( + { + **request.source.model_dump(), + "provider": "notion", + "expected_identity": workspace.hex.upper(), + "expected_user_identity": bot.hex.upper(), + "config": {"expected_workspace_id": str(uuid4()), "expected_bot_id": str(uuid4())}, + } + ) + expected = spec.source_config() + assert expected == {"expected_workspace_id": str(workspace), "expected_bot_id": str(bot)} + assert native_principal("notion", expected) == (str(workspace), str(bot)) + creator = service.store.create + creator._source_registry.get.return_value.short_name = "notion" + creator._source_validation.seed_config_result("notion", expected) + request = request.model_copy(update={"source": spec}) + async with database() as db: + first = await service.ensure(db, ctx, account, request) + epoch = (await db.get(Sync, first.sync_id)).writer_epoch + async with database() as db: + assert await service.ensure(db, ctx, account, request) == first + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + for identity_field in ("expected_identity", "expected_user_identity"): + changed = ManagedSource.model_validate({**spec.model_dump(), identity_field: str(uuid4())}) + async with database() as db: + with pytest.raises(HTTPException, match="Reconnect changes original account identity"): + await service.ensure( + db, + ctx, + account, + request.model_copy(update={"generation": 2, "source": changed}), + ) + async with database() as db: + assert await service.get(db, ctx, account) == first + assert (await db.get(Sync, first.sync_id)).writer_epoch == epoch + assert ( + await db.get(SourceConnection, first.source_connection_id) + ).config_fields == expected + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 1 + reconnect = request.model_copy( + update={ + "generation": 2, + "source": spec.model_copy(update={"connected_account_id": "ca_reconnected"}), + } + ) + async with database() as db: + second = await service.ensure(db, ctx, account, reconnect) + assert second.source_connection_id == first.source_connection_id + assert second.sync_id == first.sync_id and second.observed_generation == 2 + original = await db.get(SourceConnection, first.source_connection_id) + assert original.config_fields == expected + assert original.auth_provider_config["account_id"] == "ca_reconnected" + async with database() as db: + assert await service.ensure(db, ctx, account, reconnect) == second + assert await db.scalar(select(func.count()).select_from(SourceConnection)) == 1 + assert await db.scalar(select(func.count()).select_from(SyncJob)) == 2 + assert lifecycle.create.await_count == 2 + + +@pytest.mark.parametrize( + "workspace,bot", + [("workspace-name", str(uuid4())), (str(uuid4()), None), (str(uuid4()), "bot-name")], +) +def test_notion_provisioning_requires_native_uuid_pair(workspace, bot): + with pytest.raises(ValueError): + ManagedSource( + provider="notion", + expected_identity=workspace, + expected_user_identity=bot, + collection="owned", + auth_provider="composio", + connected_account_id="ca_test", + auth_config_id="ac_test", + user_id="owner", + cron="0 * * * *", + ) diff --git a/backend/airweave/domains/search/adapters/vector_db/filter_translator.py b/backend/airweave/domains/search/adapters/vector_db/filter_translator.py index 806ca89d5e..0c10e3601c 100644 --- a/backend/airweave/domains/search/adapters/vector_db/filter_translator.py +++ b/backend/airweave/domains/search/adapters/vector_db/filter_translator.py @@ -6,7 +6,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import Any, List, Optional, Union from airweave.core.logging import ContextualLogger @@ -170,7 +170,7 @@ def _escape_string(self, value: str) -> str: """Escape special characters for YQL string literals.""" return value.replace("\\", "\\\\").replace("'", "\\'") - def _parse_datetime_to_epoch(self, value: str, field: str) -> int: + def _parse_datetime_to_epoch(self, value: str, field: str) -> int | float: """Parse ISO datetime string to epoch seconds.""" try: # Strip surrounding quotes — LLMs sometimes wrap datetime strings @@ -178,7 +178,12 @@ def _parse_datetime_to_epoch(self, value: str, field: str) -> int: if value.endswith("Z"): value = value[:-1] + "+00:00" dt = datetime.fromisoformat(value) - return int(dt.timestamp()) + # Existing filters accept zone-less ISO values; interpret them as UTC, + # never as the deployment host timezone. New product inputs are aware. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + seconds = dt.timestamp() + return int(seconds) if seconds.is_integer() else seconds except (ValueError, AttributeError) as e: raise FilterTranslationError( f"Failed to parse datetime '{value}' for field '{field}': {e}" diff --git a/backend/airweave/domains/search/adapters/vector_db/tests/test_partial_coverage.py b/backend/airweave/domains/search/adapters/vector_db/tests/test_partial_coverage.py new file mode 100644 index 0000000000..db0a78cadb --- /dev/null +++ b/backend/airweave/domains/search/adapters/vector_db/tests/test_partial_coverage.py @@ -0,0 +1,38 @@ +"""Engine coverage survives the real adapter; only remote HTTP is mocked.""" + +from unittest.mock import MagicMock + +import pytest + +from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB +from airweave.domains.search.types import CompiledQuery + + +@pytest.mark.parametrize( + "coverage,errors,expected", + [ + ({"coverage": 100, "full": True}, [], False), + ({"coverage": 75}, [], True), + ({"coverage": 100, "degraded": {"timeout": True}}, [], True), + ({"coverage": 100, "full": False}, [], True), + ({"coverage": 100}, [{"code": 12}], True), + ], +) +async def test_partial_engine_response_preserved(coverage, errors, expected): + app = MagicMock() + response = MagicMock() + response.is_successful.return_value = True + response.json = {"root": {"coverage": coverage, "errors": errors}} + response.hits = [] + app.query.return_value = response + client = VespaVectorDB(app, MagicMock(), MagicMock()) + result = await client.execute_query( + CompiledQuery( + vector_db="vespa", + display="test", + raw={"yql": "select * from sources * where true", "params": {}}, + ) + ) + assert result.engine_partial is expected + assert result.retrieval_incomplete is expected + assert result.engine_coverage_percent == coverage["coverage"] diff --git a/backend/airweave/domains/search/adapters/vector_db/tests/test_vespa_client_name_substring.py b/backend/airweave/domains/search/adapters/vector_db/tests/test_vespa_client_name_substring.py index cab65bb5ba..1e697854c2 100644 --- a/backend/airweave/domains/search/adapters/vector_db/tests/test_vespa_client_name_substring.py +++ b/backend/airweave/domains/search/adapters/vector_db/tests/test_vespa_client_name_substring.py @@ -128,3 +128,23 @@ async def test_clause_absent_when_name_substring_none(self) -> None: await client.filter_search(filter_groups=[], collection_id="col-123") yql = app.query.call_args.kwargs["body"]["yql"] assert "name matches" not in yql + + +@pytest.mark.parametrize("fragment", ["… 原文", None, {"invalid": "shape"}]) +def test_optional_query_snippet_never_replaces_full_chunk(fragment): + client, _ = _make_client() + result = client._convert_hits_to_results( + [ + { + "fields": { + "entity_id": "chunk", + "name": "Original", + "textual_representation": "Full retained chunk", + "query_snippet": fragment, + } + } + ] + ).results[0] + assert result.textual_representation == "Full retained chunk" + assert result.query_snippet == (fragment if isinstance(fragment, str) else None) + assert "query_snippet" not in result.model_dump() diff --git a/backend/airweave/domains/search/adapters/vector_db/vespa_client.py b/backend/airweave/domains/search/adapters/vector_db/vespa_client.py index b8cef0ba0b..f9b5eefc3c 100644 --- a/backend/airweave/domains/search/adapters/vector_db/vespa_client.py +++ b/backend/airweave/domains/search/adapters/vector_db/vespa_client.py @@ -155,7 +155,14 @@ async def execute_query( f"coverage={coverage_pct:.1f}%" ) - return self._convert_hits_to_results(hits) + results = self._convert_hits_to_results(hits) + results.engine_coverage_percent = coverage.get("coverage") + results.engine_partial = bool( + coverage.get("degraded") or coverage.get("full") is False + or coverage.get("coverage", 100) < 100 or root.get("errors") + ) + results.retrieval_incomplete = results.engine_partial + return results async def count( self, @@ -377,6 +384,10 @@ def _build_params( RetrievalStrategy.SEMANTIC, RetrievalStrategy.HYBRID, ): + # Filter-first graph traversal can miss all eligible neighbors in + # selectively scoped collections. Keep pre-filtering, but compute + # distances before checking the filter to preserve candidate recall. + params["ranking.matching.filterFirstThreshold"] = 0.0 for i, dense_emb in enumerate(embeddings.dense_embeddings): params[f"input.query(q{i})"] = {"values": dense_emb.vector} @@ -452,9 +463,16 @@ def _convert_hits_to_results(self, hits: List[Dict[str, Any]]) -> SearchResults: textual_representation=self._get_required_field( fields, "textual_representation", entity_id ), + query_snippet=( + fields.get("query_snippet") + if isinstance(fields.get("query_snippet"), str) + else None + ), airweave_system_metadata=self._extract_system_metadata(fields, entity_id), access=self._extract_access_control(fields), - web_url=self._get_required_field(raw_source_fields, "web_url", entity_id), + # Native snapshots can be opened by canonical record ID without + # a provider web URL. Absence is valid, not a malformed index hit. + web_url=str(raw_source_fields.get("web_url") or ""), url=fields.get("url"), raw_source_fields=raw_source_fields, ) diff --git a/backend/airweave/domains/search/agentic/agent.py b/backend/airweave/domains/search/agentic/agent.py index 75b0da96a2..e7bf6d43a1 100644 --- a/backend/airweave/domains/search/agentic/agent.py +++ b/backend/airweave/domains/search/agentic/agent.py @@ -47,6 +47,7 @@ from airweave.core.protocols.reranker import RerankerProtocol from airweave.core.protocols.tokenizer import TokenizerProtocol from airweave.domains.collections.protocols import CollectionRepositoryProtocol +from airweave.domains.search.adapters.vector_db.exceptions import VectorDBError from airweave.domains.search.adapters.vector_db.protocol import VectorDBProtocol from airweave.domains.search.agentic.context_manager import ContextManager from airweave.domains.search.agentic.exceptions import ( @@ -85,12 +86,15 @@ ToolErrorResult, ToolName, ) +from airweave.domains.search.canonical_visibility import visible_results from airweave.domains.search.config import SearchConfig from airweave.domains.search.protocols import ( CollectionMetadataBuilderProtocol, SearchPlanExecutorProtocol, ) from airweave.domains.search.types import SearchResults +from airweave.domains.search.visible_vector_db import VisibleVectorDB +from airweave.domains.sources.protocols import SourceRegistryProtocol if TYPE_CHECKING: from airweave.schemas.search_v2 import AgenticSearchRequest @@ -110,6 +114,7 @@ def __init__( collection_repo: CollectionRepositoryProtocol, event_bus: EventBus, config: SearchConfig, + source_registry: SourceRegistryProtocol, ) -> None: """Initialize with all dependencies (injected by service).""" self._llm = llm @@ -117,6 +122,7 @@ def __init__( self._reranker = reranker self._executor = executor self._vector_db = vector_db + self._source_registry = source_registry self._metadata_builder = metadata_builder self._collection_repo = collection_repo self._event_bus = event_bus @@ -238,6 +244,14 @@ async def _run( # noqa: C901 — agent loop orchestration is inherently complex for iteration in range(max_iter): diag.iteration = iteration + # Never resend cached text after its authoritative publication changes. + current = await visible_results( + db, ctx.organization.id, readable_id, list(state.results.values()), + self._source_registry, + ) + if len(current) != len(state.results): + raise VectorDBError("Source content changed during search; retry the query") + # 1. Call LLM llm_start = time.monotonic() response = await self._llm.chat( @@ -395,6 +409,14 @@ async def _run( # noqa: C901 — agent loop orchestration is inherently complex state.results[eid] for eid in state.collected_ids if eid in state.results ] + collected_before_validation = len(collected_results) + collected_results = await visible_results( + db, ctx.organization.id, readable_id, collected_results, self._source_registry + ) + excluded_at_finish = collected_before_validation - len(collected_results) + state.retrieval_incomplete |= excluded_at_finish > 0 + state.excluded_candidates += excluded_at_finish + # Optional reranking if self._reranker and collected_results: rerank_start = time.monotonic() @@ -472,7 +494,11 @@ async def _run( # noqa: C901 — agent loop orchestration is inherently complex ) ) - return SearchResults(results=collected_results) + return SearchResults( + results=collected_results, + retrieval_incomplete=state.retrieval_incomplete, + excluded_candidates=state.excluded_candidates, + ) @staticmethod def _append_iteration_messages( @@ -599,6 +625,9 @@ def _build_dispatcher( user_principal: str | None = None, ) -> ToolDispatcher: """Construct tools and dispatcher for this request.""" + visible_db = VisibleVectorDB( + self._vector_db, db, ctx, collection_readable_id, collection_id, self._source_registry + ) return ToolDispatcher( { ToolName.SEARCH: SearchTool( @@ -611,27 +640,27 @@ def _build_dispatcher( user_principal=user_principal, ), ToolName.READ: ReadTool( - vector_db=self._vector_db, + vector_db=visible_db, collection_id=collection_id, surrounding_chunks=self._config.READ_SURROUNDING_CHUNKS, ), ToolName.ADD_TO_RESULTS: AddToResultsTool(), ToolName.REMOVE_FROM_RESULTS: RemoveFromResultsTool(), ToolName.COUNT: CountTool( - vector_db=self._vector_db, + vector_db=visible_db, collection_id=collection_id, user_filter=user_filter, ), ToolName.GET_CHILDREN: GetChildrenTool( - vector_db=self._vector_db, + vector_db=visible_db, collection_id=collection_id, ), ToolName.GET_SIBLINGS: GetSiblingsTool( - vector_db=self._vector_db, + vector_db=visible_db, collection_id=collection_id, ), ToolName.GET_PARENT: GetParentTool( - vector_db=self._vector_db, + vector_db=visible_db, collection_id=collection_id, ), ToolName.REVIEW_RESULTS: ReviewResultsTool(), diff --git a/backend/airweave/domains/search/agentic/context_manager.py b/backend/airweave/domains/search/agentic/context_manager.py index b10cb59710..643e2c4687 100644 --- a/backend/airweave/domains/search/agentic/context_manager.py +++ b/backend/airweave/domains/search/agentic/context_manager.py @@ -288,10 +288,17 @@ def fit_tool_result( def _fit_search_result(self, result: SearchToolResult, available_tokens: int) -> str: """Fit search summaries into budget, dropping from bottom.""" + notice = "" + if result.retrieval_incomplete or result.excluded_candidates: + notice = ( + f"Retrieval incomplete: {result.excluded_candidates} stale or inaccessible " + "candidates were excluded. An empty or short page does not prove " + "that no other matches exist.\n\n" + ) if not result.summaries: - return "No results found." + return notice + ("No currently visible results." if notice else "No results found.") - header = f"**{len(result.summaries)} results** ({result.new_count} new):\n\n" + header = notice + f"**{len(result.summaries)} results** ({result.new_count} new):\n\n" header_tokens = self._tokenizer.count_tokens(header) remaining = available_tokens - header_tokens diff --git a/backend/airweave/domains/search/agentic/service.py b/backend/airweave/domains/search/agentic/service.py index 7f15fd193e..4db02e07d8 100644 --- a/backend/airweave/domains/search/agentic/service.py +++ b/backend/airweave/domains/search/agentic/service.py @@ -24,6 +24,7 @@ SearchPlanExecutorProtocol, ) from airweave.domains.search.types import SearchResults +from airweave.domains.sources.protocols import SourceRegistryProtocol if TYPE_CHECKING: from airweave.schemas.search_v2 import AgenticSearchRequest @@ -42,6 +43,7 @@ def __init__( metadata_builder: CollectionMetadataBuilderProtocol, collection_repo: CollectionRepositoryProtocol, event_bus: EventBus, + source_registry: SourceRegistryProtocol, ) -> None: """Initialize with all dependencies needed to construct agents.""" self._llm = llm @@ -49,6 +51,7 @@ def __init__( self._reranker = reranker self._executor = executor self._vector_db = vector_db + self._source_registry = source_registry self._metadata_builder = metadata_builder self._collection_repo = collection_repo self._event_bus = event_bus @@ -61,6 +64,7 @@ def with_llm(self, llm: LLMProtocol) -> "AgenticSearchService": reranker=self._reranker, executor=self._executor, vector_db=self._vector_db, + source_registry=self._source_registry, metadata_builder=self._metadata_builder, collection_repo=self._collection_repo, event_bus=self._event_bus, @@ -81,6 +85,7 @@ async def search( reranker=self._reranker, executor=self._executor, vector_db=self._vector_db, + source_registry=self._source_registry, metadata_builder=self._metadata_builder, collection_repo=self._collection_repo, event_bus=self._event_bus, diff --git a/backend/airweave/domains/search/agentic/state.py b/backend/airweave/domains/search/agentic/state.py index cf0b877ece..6249449b47 100644 --- a/backend/airweave/domains/search/agentic/state.py +++ b/backend/airweave/domains/search/agentic/state.py @@ -29,6 +29,9 @@ def __init__(self) -> None: self.collected_ids: set[str] = set() self.should_finish: bool = False self.return_warned: bool = False + self.retrieval_incomplete: bool = False + # Candidate occurrences across calls, not a unique-record total. + self.excluded_candidates: int = 0 def add_to_collected(self, entity_ids: list[str]) -> tuple[list[str], list[str], list[str]]: """Add entity IDs to the collected result set. diff --git a/backend/airweave/domains/search/agentic/tests/test_agent.py b/backend/airweave/domains/search/agentic/tests/test_agent.py index bf13b4b20e..56a9dbacde 100644 --- a/backend/airweave/domains/search/agentic/tests/test_agent.py +++ b/backend/airweave/domains/search/agentic/tests/test_agent.py @@ -33,11 +33,24 @@ from airweave.domains.search.fakes.executor import FakeSearchPlanExecutor from airweave.domains.search.fakes.metadata_builder import FakeCollectionMetadataBuilder from airweave.domains.search.types import SearchResults +from airweave.domains.sources.fakes.registry import FakeSourceRegistry from airweave.models.collection import Collection from airweave.schemas.search_v2 import AgenticSearchRequest # ── Test constants ──────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def isolated_publication_gate(monkeypatch): + """Synthetic orchestration fixtures do not query the real publication database.""" + + async def passthrough(db, organization_id, collection, results, registry): + return results + + monkeypatch.setattr("airweave.domains.search.visible_vector_db.visible_results", passthrough) + monkeypatch.setattr("airweave.domains.search.agentic.agent.visible_results", passthrough) + + DEFAULT_ORG_ID = uuid4() DEFAULT_COLLECTION_ID = uuid4() DEFAULT_READABLE_ID = "test-col" @@ -167,6 +180,7 @@ def _build_agent( reranker=None, executor=executor or FakeSearchPlanExecutor(), vector_db=FakeVectorDB(), + source_registry=FakeSourceRegistry(), metadata_builder=metadata_builder, collection_repo=collection_repo, event_bus=event_bus or FakeEventBus(), @@ -448,9 +462,7 @@ async def test_llm_fatal_error_emits_failed_event(self) -> None: async def test_llm_all_providers_failed_emits_failed_event(self) -> None: """LLMAllProvidersFailedError → SearchFailedEvent published.""" llm = FakeLLM(make_model_spec()) - llm.seed_error( - LLMAllProvidersFailedError([("p1", LLMFatalError("down", provider="p1"))]) - ) + llm.seed_error(LLMAllProvidersFailedError([("p1", LLMFatalError("down", provider="p1"))])) event_bus = FakeEventBus() agent = _build_agent(llm=llm, event_bus=event_bus) @@ -622,3 +634,96 @@ async def test_no_reranker_returns_unranked(self) -> None: # Completed event should still be emitted completed = event_bus.get_events("search.completed") assert len(completed) == 1 + + +@pytest.mark.asyncio +async def test_revoked_cached_text_stops_before_next_llm_call(monkeypatch): + """A result found on turn one must be revalidated before turn two sees its text.""" + from airweave.domains.search.adapters.vector_db.exceptions import VectorDBError + + executor = FakeSearchPlanExecutor() + executor.seed_result(SearchResults(results=[make_result("revoked", content="private text")])) + llm = FakeLLM(make_model_spec()) + llm.seed_tool_response(_make_search_response(tool_calls=[_search_tool_call()])) + llm.seed_tool_response(_make_search_response(tool_calls=[_return_results_tool_call()])) + gate = AsyncMock(return_value=[]) + monkeypatch.setattr("airweave.domains.search.agentic.agent.visible_results", gate) + agent = _build_agent(llm=llm, executor=executor) + with pytest.raises(VectorDBError, match="changed during search"): + await agent.run(AsyncMock(), _make_ctx(), DEFAULT_READABLE_ID, _make_request()) + assert len([call for call in llm._calls if call[0] == "chat"]) == 1 + assert gate.await_count == 2 + assert gate.await_args.args[3][0].entity_id == "revoked" + + +def test_all_original_read_and_navigation_tools_receive_visibility_wrapper(): + """Composition must not accidentally hand the raw adapter to one navigation tool.""" + from airweave.domains.search.visible_vector_db import VisibleVectorDB + + agent = _build_agent() + dispatcher = agent._build_dispatcher( + str(DEFAULT_COLLECTION_ID), + [], + AsyncMock(), + _make_ctx(), + DEFAULT_READABLE_ID, + ) + for name in ("read", "count", "get_children", "get_siblings", "get_parent"): + assert isinstance(dispatcher._tools[name]._vector_db, VisibleVectorDB) + + +@pytest.mark.asyncio +async def test_final_response_preserves_search_exclusions_and_finish_time_drop(monkeypatch): + result = make_result("fresh-until-finish") + executor = FakeSearchPlanExecutor() + executor.seed_result( + SearchResults(results=[result], retrieval_incomplete=True, excluded_candidates=2) + ) + llm = FakeLLM(make_model_spec()) + llm.seed_tool_response(_make_search_response(tool_calls=[_search_tool_call()])) + llm.seed_tool_response( + _make_search_response( + tool_calls=[ + _add_results_tool_call(entity_ids=[result.entity_id]), + _return_results_tool_call(), + ] + ) + ) + gate = AsyncMock(side_effect=[[], [result], []]) + monkeypatch.setattr("airweave.domains.search.agentic.agent.visible_results", gate) + response = await _build_agent(llm=llm, executor=executor).run( + AsyncMock(), + _make_ctx(), + DEFAULT_READABLE_ID, + _make_request(), + ) + assert response.results == [] + assert response.retrieval_incomplete is True + assert response.excluded_candidates == 3 + + +@pytest.mark.asyncio +async def test_unavailable_count_allows_agent_to_continue_with_other_tools(monkeypatch): + from airweave.domains.search.visible_vector_db import UnavailableExactCount + + monkeypatch.setattr( + "airweave.domains.search.visible_vector_db.VisibleVectorDB.count", + AsyncMock(side_effect=UnavailableExactCount("exact count unavailable")), + ) + llm = FakeLLM(make_model_spec()) + llm.seed_tool_response( + _make_search_response( + tool_calls=[ + LLMToolCall(id="count-1", name="count", arguments={"filter_groups": []}), + ] + ) + ) + llm.seed_tool_response(_make_search_response(tool_calls=[_return_results_tool_call()])) + response = await _build_agent(llm=llm).run( + AsyncMock(), + _make_ctx(), + DEFAULT_READABLE_ID, + _make_request(), + ) + assert response.results == [] + assert len([call for call in llm._calls if call[0] == "chat"]) == 2 diff --git a/backend/airweave/domains/search/agentic/tests/test_context_manager.py b/backend/airweave/domains/search/agentic/tests/test_context_manager.py index 8f562b93b6..c0d3dec71c 100644 --- a/backend/airweave/domains/search/agentic/tests/test_context_manager.py +++ b/backend/airweave/domains/search/agentic/tests/test_context_manager.py @@ -311,8 +311,9 @@ def test_triggers_emergency_compress_when_nearly_full(self) -> None: messages = [{"role": "user", "content": "query"}] for i in range(80): messages.append({"role": "assistant", "content": f"thinking {i}" * 30}) - messages.append({"role": "tool", "tool_call_id": f"tc-{i}", - "content": f"result {i}" * 30}) + messages.append( + {"role": "tool", "tool_call_id": f"tc-{i}", "content": f"result {i}" * 30} + ) # Messages should be large enough to trigger emergency compress assert cm.input_tokens(messages) > cm._context_window - cm._MIN_OUTPUT_TOKENS @@ -350,11 +351,20 @@ def test_counts_tool_calls_in_input(self) -> None: messages_without = [{"role": "user", "content": "query"}] messages_with = [ {"role": "user", "content": "query"}, - {"role": "assistant", "content": "", "tool_calls": [ - {"id": "tc-1", "type": "function", "function": { - "name": "search", "arguments": "x" * 4_000, - }} - ]}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "tc-1", + "type": "function", + "function": { + "name": "search", + "arguments": "x" * 4_000, + }, + } + ], + }, ] result_without = cm.max_output_tokens(messages_without) result_with = cm.max_output_tokens(messages_with) @@ -429,8 +439,9 @@ def test_actually_reduces_token_count(self) -> None: messages = [{"role": "user", "content": "query"}] for i in range(20): messages.append({"role": "assistant", "content": f"thinking {'x' * 500} {i}"}) - messages.append({"role": "tool", "tool_call_id": f"tc-{i}", - "content": f"result {'y' * 500} {i}"}) + messages.append( + {"role": "tool", "tool_call_id": f"tc-{i}", "content": f"result {'y' * 500} {i}"} + ) tokens_before = cm.input_tokens(messages) result = cm.emergency_compress(messages) @@ -451,7 +462,6 @@ def test_reserves_output_space(self) -> None: messages = [{"role": "user", "content": "query"}] budget = cm.available_budget(messages) - max_out = cm.max_output_tokens(messages) # budget + output + input should not exceed context_window input_tokens = cm.input_tokens(messages) @@ -498,16 +508,54 @@ def test_counts_thinking(self) -> None: """_thinking field on assistant messages is counted.""" cm = make_context_mgr() without = cm.input_tokens([{"role": "assistant", "content": "hi"}]) - with_thinking = cm.input_tokens([ - {"role": "assistant", "content": "hi", "_thinking": "x" * 1000} - ]) + with_thinking = cm.input_tokens( + [{"role": "assistant", "content": "hi", "_thinking": "x" * 1000}] + ) assert with_thinking > without def test_counts_tool_calls(self) -> None: """tool_calls on assistant messages are counted.""" cm = make_context_mgr() without = cm.input_tokens([{"role": "assistant", "content": "hi"}]) - with_tc = cm.input_tokens([{"role": "assistant", "content": "hi", "tool_calls": [ - {"id": "tc-1", "type": "function", "function": {"name": "s", "arguments": "x" * 500}} - ]}]) + with_tc = cm.input_tokens( + [ + { + "role": "assistant", + "content": "hi", + "tool_calls": [ + { + "id": "tc-1", + "type": "function", + "function": {"name": "s", "arguments": "x" * 500}, + } + ], + } + ] + ) assert with_tc > without + + +def test_empty_filtered_search_keeps_incomplete_notice(): + cm = make_context_mgr() + result = SearchToolResult( + summaries=[], new_count=0, retrieval_incomplete=True, excluded_candidates=4 + ) + content = cm.fit_tool_result(result, 1000) + assert "Retrieval incomplete" in content + assert "4 stale or inaccessible" in content + assert "No results found" not in content + + +def test_incomplete_notice_survives_context_budget_truncation(): + cm = make_context_mgr() + result = SearchToolResult( + summaries=[ + make_rendered_result(entity_id=str(i), text="long text " * 100) for i in range(3) + ], + new_count=3, + retrieval_incomplete=True, + excluded_candidates=2, + ) + content = cm.fit_tool_result(result, 50) + assert "Retrieval incomplete" in content + assert "context budget" in content diff --git a/backend/airweave/domains/search/agentic/tests/test_tools.py b/backend/airweave/domains/search/agentic/tests/test_tools.py index e45a3347bb..10ed29b5ab 100644 --- a/backend/airweave/domains/search/agentic/tests/test_tools.py +++ b/backend/airweave/domains/search/agentic/tests/test_tools.py @@ -38,7 +38,14 @@ async def test_new_results_tracked_in_state(self) -> None: executor = FakeSearchPlanExecutor() executor.seed_result(SearchResults(results=[r1, r2])) - tool = SearchTool(executor=executor, user_filter=[], collection_id="col-1", db=AsyncMock(), ctx=AsyncMock(), collection_readable_id="col-readable") + tool = SearchTool( + executor=executor, + user_filter=[], + collection_id="col-1", + db=AsyncMock(), + ctx=AsyncMock(), + collection_readable_id="col-readable", + ) state = make_state() result = await tool.execute( { @@ -64,7 +71,14 @@ async def test_duplicate_results_not_counted_as_new(self) -> None: executor.seed_result(SearchResults(results=[r1, r2])) state = make_state(results={"ent-1": r1}) - tool = SearchTool(executor=executor, user_filter=[], collection_id="col-1", db=AsyncMock(), ctx=AsyncMock(), collection_readable_id="col-readable") + tool = SearchTool( + executor=executor, + user_filter=[], + collection_id="col-1", + db=AsyncMock(), + ctx=AsyncMock(), + collection_readable_id="col-readable", + ) result = await tool.execute( { "query": {"primary": "test"}, @@ -86,7 +100,14 @@ async def test_lineage_tracked(self) -> None: executor.seed_result(SearchResults(results=[r1])) state = make_state() - tool = SearchTool(executor=executor, user_filter=[], collection_id="col-1", db=AsyncMock(), ctx=AsyncMock(), collection_readable_id="col-readable") + tool = SearchTool( + executor=executor, + user_filter=[], + collection_id="col-1", + db=AsyncMock(), + ctx=AsyncMock(), + collection_readable_id="col-readable", + ) await tool.execute( { "query": {"primary": "test"}, @@ -555,3 +576,43 @@ async def test_dispatcher_passes_through_tool_validation_error(self) -> None: with pytest.raises(ToolValidationError, match="bad args"): await dispatcher.dispatch(tc, make_state()) + + +@pytest.mark.asyncio +async def test_search_accumulates_excluded_candidates_without_claiming_empty_complete(): + executor = FakeSearchPlanExecutor() + executor.seed_result_sequence( + [ + SearchResults(results=[], retrieval_incomplete=True, excluded_candidates=2), + SearchResults(results=[], retrieval_incomplete=True, excluded_candidates=3), + ] + ) + tool = SearchTool(executor, [], "col", AsyncMock(), AsyncMock(), "readable") + state = make_state() + first = await tool.execute( + {"query": {"primary": "first"}, "limit": 10, "offset": 0, "retrieval_strategy": "hybrid"}, + state, + ) + second = await tool.execute( + {"query": {"primary": "second"}, "limit": 10, "offset": 0, "retrieval_strategy": "hybrid"}, + state, + ) + assert first.retrieval_incomplete is True + assert first.excluded_candidates == 2 + assert second.excluded_candidates == 3 + assert state.retrieval_incomplete is True + assert state.excluded_candidates == 5 + + +@pytest.mark.asyncio +async def test_unavailable_exact_count_is_correctable_not_a_transient_outage(): + from airweave.domains.search.agentic.exceptions import ToolExecutionError, ToolTransientError + from airweave.domains.search.visible_vector_db import UnavailableExactCount + + vector = AsyncMock() + vector.count.side_effect = UnavailableExactCount("not supported") + tool = CountTool(vector, "collection", []) + with pytest.raises(ToolExecutionError, match="Search or browse") as failure: + await tool.execute({"filter_groups": []}, make_state()) + assert not isinstance(failure.value, ToolTransientError) + assert "do not infer zero" in str(failure.value) diff --git a/backend/airweave/domains/search/agentic/tools/count.py b/backend/airweave/domains/search/agentic/tools/count.py index 56092edabd..070640b8df 100644 --- a/backend/airweave/domains/search/agentic/tools/count.py +++ b/backend/airweave/domains/search/agentic/tools/count.py @@ -5,10 +5,12 @@ from typing import Any from airweave.domains.search.adapters.vector_db.protocol import VectorDBProtocol +from airweave.domains.search.agentic.exceptions import ToolExecutionError from airweave.domains.search.agentic.state import AgentState from airweave.domains.search.agentic.tools.dispatcher import Tool from airweave.domains.search.agentic.tools.types import CountToolResult from airweave.domains.search.types.filters import FilterGroup +from airweave.domains.search.visible_vector_db import UnavailableExactCount COUNT_TOOL: dict[str, Any] = { "type": "function", @@ -76,5 +78,12 @@ async def execute( else: combined = validated - count = await self._vector_db.count(combined, self._collection_id) + try: + count = await self._vector_db.count(combined, self._collection_id) + except UnavailableExactCount as exc: + raise ToolExecutionError( + "Exact current count is unavailable for this indexed collection. " + "Search or browse the available records instead; do not infer zero. " + "Retrying count does not resolve this capability limitation." + ) from exc return CountToolResult(count=count) diff --git a/backend/airweave/domains/search/agentic/tools/read.py b/backend/airweave/domains/search/agentic/tools/read.py index 771f8b40fb..ea112b2b8d 100644 --- a/backend/airweave/domains/search/agentic/tools/read.py +++ b/backend/airweave/domains/search/agentic/tools/read.py @@ -85,6 +85,9 @@ async def execute( for orig_id, group_results in groups.items(): chunks = await self._fetch_chunks(orig_id, group_results, state) + if not chunks: + not_found.extend(result.entity_id for result in group_results) + continue all_read_results.extend(chunks) # Side-load fetched chunks into state @@ -156,14 +159,11 @@ async def _fetch_chunks( ) ] - try: - results = await self._vector_db.filter_search( - filter_groups=filter_groups, - collection_id=self._collection_id, - limit=max(max_chunk - min_chunk + 1, 10), - ) - except Exception: - results = list(group_results) + results = await self._vector_db.filter_search( + filter_groups=filter_groups, + collection_id=self._collection_id, + limit=max(max_chunk - min_chunk + 1, 10), + ) results.sort(key=lambda r: r.airweave_system_metadata.chunk_index) return results diff --git a/backend/airweave/domains/search/agentic/tools/search.py b/backend/airweave/domains/search/agentic/tools/search.py index 20a6e53c17..3c5710359e 100644 --- a/backend/airweave/domains/search/agentic/tools/search.py +++ b/backend/airweave/domains/search/agentic/tools/search.py @@ -72,6 +72,11 @@ async def execute( user_principal=self._user_principal, ) + state.retrieval_incomplete |= ( + results.retrieval_incomplete or results.excluded_candidates > 0 + ) + state.excluded_candidates += results.excluded_candidates + # Track new results in state new_count = 0 for r in results.results: @@ -93,4 +98,6 @@ async def execute( new_count=new_count, requested_limit=plan.limit, requested_offset=plan.offset, + retrieval_incomplete=results.retrieval_incomplete, + excluded_candidates=results.excluded_candidates, ) diff --git a/backend/airweave/domains/search/agentic/tools/types.py b/backend/airweave/domains/search/agentic/tools/types.py index e18bc73e55..784749063b 100644 --- a/backend/airweave/domains/search/agentic/tools/types.py +++ b/backend/airweave/domains/search/agentic/tools/types.py @@ -57,6 +57,8 @@ class SearchToolResult: new_count: int requested_limit: int = 0 # for pagination warning requested_offset: int = 0 # for pagination warning + retrieval_incomplete: bool = False + excluded_candidates: int = 0 @dataclass(frozen=True) diff --git a/backend/airweave/domains/search/browse/service.py b/backend/airweave/domains/search/browse/service.py index 8125d279ae..6639f5fac5 100644 --- a/backend/airweave/domains/search/browse/service.py +++ b/backend/airweave/domains/search/browse/service.py @@ -6,7 +6,6 @@ from __future__ import annotations -import asyncio import time from typing import TYPE_CHECKING @@ -23,6 +22,8 @@ FilterGroup, FilterOperator, ) +from airweave.domains.search.visible_vector_db import UnavailableExactCount, VisibleVectorDB +from airweave.domains.sources.protocols import SourceRegistryProtocol from airweave.schemas.search_v2 import BrowseResponse if TYPE_CHECKING: @@ -36,9 +37,11 @@ def __init__( self, vector_db: VectorDBProtocol, collection_repo: CollectionRepositoryProtocol, + source_registry: SourceRegistryProtocol, ) -> None: """Initialize with vector DB and collection repository.""" self._vector_db = vector_db + self._source_registry = source_registry self._collection_repo = collection_repo async def browse( @@ -62,20 +65,19 @@ async def browse( collection_id = str(collection.id) name_substring = (request.name_query or "").strip() or None - results, total = await asyncio.gather( - self._vector_db.filter_search( - filter_groups=filter_groups, - collection_id=collection_id, - limit=request.limit, - offset=request.offset, - name_substring=name_substring, - ), - self._vector_db.count( - filter_groups=filter_groups, - collection_id=collection_id, - name_substring=name_substring, - ), + visible_db = VisibleVectorDB( + self._vector_db, db, ctx, readable_id, collection_id, self._source_registry ) + # One SQL session is never used concurrently by validation/count. + retrieved = await self._vector_db.filter_search( + filter_groups=filter_groups, collection_id=collection_id, + limit=request.limit, offset=request.offset, name_substring=name_substring, + ) + results = await visible_db.validate(retrieved) + try: + total = await visible_db.count(filter_groups, collection_id, name_substring) + except UnavailableExactCount: + total = None duration_ms = int((time.monotonic() - start) * 1000) ctx.logger.info( @@ -86,6 +88,7 @@ async def browse( return BrowseResponse( results=results, total=total, + retrieval_incomplete=len(results) != len(retrieved), limit=request.limit, offset=request.offset, ) diff --git a/backend/airweave/domains/search/browse/tests/test_service.py b/backend/airweave/domains/search/browse/tests/test_service.py index 713da28713..4a5fce6743 100644 --- a/backend/airweave/domains/search/browse/tests/test_service.py +++ b/backend/airweave/domains/search/browse/tests/test_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone +from types import SimpleNamespace from unittest.mock import AsyncMock from uuid import uuid4 @@ -16,15 +17,28 @@ from airweave.domains.search.adapters.vector_db.fakes.vector_db import FakeVectorDB from airweave.domains.search.agentic.tests.conftest import make_result from airweave.domains.search.browse.service import BrowseService +from airweave.domains.search.tests.test_executor import _make_registry_entry from airweave.domains.search.types.filters import ( + FilterableField, FilterCondition, FilterGroup, FilterOperator, - FilterableField, ) +from airweave.domains.sources.fakes.registry import FakeSourceRegistry from airweave.models.collection import Collection from airweave.schemas.search_v2 import BrowseRequest + +@pytest.fixture(autouse=True) +def isolated_publication_gate(monkeypatch): + """Synthetic orchestration fixtures do not query the real publication database.""" + + async def passthrough(db, organization_id, collection, results, registry): + return results + + monkeypatch.setattr("airweave.domains.search.visible_vector_db.visible_results", passthrough) + + DEFAULT_ORG_ID = uuid4() DEFAULT_COLLECTION_ID = uuid4() DEFAULT_READABLE_ID = "test-col" @@ -64,10 +78,20 @@ def _make_collection() -> Collection: return col +def _make_db(): + db = AsyncMock() + db.scalars.return_value = [SimpleNamespace(short_name="legacy", sync_id=uuid4())] + return db + + def _make_service() -> tuple[BrowseService, FakeVectorDB, FakeCollectionRepository]: vector_db = FakeVectorDB() collection_repo = FakeCollectionRepository() - svc = BrowseService(vector_db=vector_db, collection_repo=collection_repo) + registry = FakeSourceRegistry() + registry.seed(_make_registry_entry("legacy")) + svc = BrowseService( + vector_db=vector_db, collection_repo=collection_repo, source_registry=registry + ) return svc, vector_db, collection_repo @@ -84,7 +108,7 @@ async def test_happy_path_returns_response(self) -> None: vector_db.seed_count(42) response = await svc.browse( - AsyncMock(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(limit=10, offset=0) + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(limit=10, offset=0) ) assert response.total == 42 @@ -98,7 +122,7 @@ async def test_collection_not_found_raises_404(self) -> None: svc, _, _ = _make_service() with pytest.raises(HTTPException) as exc_info: - await svc.browse(AsyncMock(), _make_ctx(), "nonexistent", BrowseRequest()) + await svc.browse(_make_db(), _make_ctx(), "nonexistent", BrowseRequest()) assert exc_info.value.status_code == 404 assert "nonexistent" in exc_info.value.detail @@ -109,7 +133,7 @@ async def test_chunk_index_anchor_added_to_filter_groups(self) -> None: svc, vector_db, repo = _make_service() repo.seed_readable(DEFAULT_READABLE_ID, _make_collection()) - await svc.browse(AsyncMock(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest()) + await svc.browse(_make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest()) filter_call = next(c for c in vector_db._calls if c[0] == "filter_search") filter_groups = filter_call[1] @@ -124,7 +148,7 @@ async def test_sync_ids_translated_to_filter(self) -> None: sync_id = str(uuid4()) await svc.browse( - AsyncMock(), + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(sync_ids=[sync_id]), @@ -145,7 +169,7 @@ async def test_entity_types_translated_to_filter(self) -> None: repo.seed_readable(DEFAULT_READABLE_ID, _make_collection()) await svc.browse( - AsyncMock(), + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(entity_types=["NotionPageEntity", "SlackMessageEntity"]), @@ -186,7 +210,7 @@ async def test_user_filter_combined_with_anchor(self) -> None: ), ] await svc.browse( - AsyncMock(), + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(filter=user_filter), @@ -210,7 +234,7 @@ async def test_name_query_passed_as_name_substring(self) -> None: repo.seed_readable(DEFAULT_READABLE_ID, _make_collection()) await svc.browse( - AsyncMock(), + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(name_query=" Quick "), @@ -229,7 +253,7 @@ async def test_blank_name_query_treated_as_none(self) -> None: repo.seed_readable(DEFAULT_READABLE_ID, _make_collection()) await svc.browse( - AsyncMock(), + _make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest(name_query=" "), @@ -251,3 +275,24 @@ def test_sync_ids_rejects_oversized_list(self) -> None: with pytest.raises(ValidationError): BrowseRequest(sync_ids=[str(uuid4()) for _ in range(101)]) + + +@pytest.mark.asyncio +async def test_browse_filters_before_output_and_reports_unknown_canonical_count(monkeypatch): + from airweave.domains.search.visible_vector_db import UnavailableExactCount + + svc, vector_db, repo = _make_service() + repo.seed_readable(DEFAULT_READABLE_ID, _make_collection()) + fresh, stale = make_result("fresh"), make_result("stale") + vector_db.seed_filter_results([fresh, stale]) + gate = AsyncMock(return_value=[fresh]) + monkeypatch.setattr("airweave.domains.search.visible_vector_db.visible_results", gate) + monkeypatch.setattr( + "airweave.domains.search.visible_vector_db.VisibleVectorDB.count", + AsyncMock(side_effect=UnavailableExactCount("unknown")), + ) + response = await svc.browse(_make_db(), _make_ctx(), DEFAULT_READABLE_ID, BrowseRequest()) + assert response.results == [fresh] + assert response.total is None + assert response.retrieval_incomplete is True + assert gate.await_args.args[3] == [fresh, stale] diff --git a/backend/airweave/domains/search/builders/collection_metadata.py b/backend/airweave/domains/search/builders/collection_metadata.py index fc8984bb95..509daf2363 100644 --- a/backend/airweave/domains/search/builders/collection_metadata.py +++ b/backend/airweave/domains/search/builders/collection_metadata.py @@ -8,6 +8,7 @@ from airweave.api.context import ApiContext from airweave.domains.collections.protocols import CollectionRepositoryProtocol +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore from airweave.domains.entities.protocols import ( EntityCountRepositoryProtocol, EntityDefinitionRegistryProtocol, @@ -181,6 +182,10 @@ class CollectionMetadataBuilder: "A modern knowledge base and team wiki for documenting and sharing " "information with powerful search and integrations." ), + "wispr": ( + "Captured meeting notes and transcripts from Wispr. Search covers the " + "content exposed by the connected account; capture may be partial." + ), "slack": ( "A team communication platform featuring channels, direct messages, and " "integrations for real-time collaboration." @@ -315,12 +320,25 @@ async def build( # 3. Build metadata for each source connection sources: list[SourceMetadata] = [] for sc in source_connections: + if not sc.is_authenticated: + continue # Get entity definitions this source can produce (in-memory, sync) entity_definitions = self._entity_definition_registry.list_for_source(sc.short_name) - # Get entity counts for this source connection's sync + entry = self._source_registry.get(sc.short_name) + canonical_types = tuple(getattr(entry.source_class_ref, "canonical_record_types", ())) + captured_counts = None + if canonical_types: + counts = ( + await CanonicalQueryStore().captured_counts(db, ctx.organization.id, sc.sync_id) + if sc.sync_id + else {} + ) + captured_counts = {kind: counts.get(kind, 0) for kind in canonical_types} + + # Legacy counts describe indexed entity classes, not captured records. counts_by_short_name: dict[str, int] = {} - if sc.sync_id: + if sc.sync_id and not canonical_types: entity_counts = await self._entity_count_repo.get_counts_per_sync_and_type( db, sc.sync_id ) @@ -330,7 +348,9 @@ async def build( # Build entity type metadata with fields and counts entity_types: list[EntityTypeMetadata] = [] for entity_def in entity_definitions: - count = counts_by_short_name.get(entity_def.short_name, 0) + count = ( + None if canonical_types else counts_by_short_name.get(entity_def.short_name, 0) + ) fields = self._extract_fields(entity_def.entity_schema) entity_types.append( @@ -349,7 +369,8 @@ async def build( short_name=sc.short_name, description=self._get_source_description(sc.short_name), entity_types=entity_types, - federated=entry.federated_search, + federated=entry.federated_search and not canonical_types, + captured_record_counts=captured_counts, ) ) diff --git a/backend/airweave/domains/search/canonical_visibility.py b/backend/airweave/domains/search/canonical_visibility.py new file mode 100644 index 0000000000..2a6aebb03a --- /dev/null +++ b/backend/airweave/domains/search/canonical_visibility.py @@ -0,0 +1,115 @@ +"""Reject stale index publications before any reranking or model consumption.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import publications_match +from airweave.domains.entities.canonical.source import indexed_record_types +from airweave.domains.search.types.results import SearchResult +from airweave.domains.sources.protocols import SourceRegistryProtocol +from airweave.models.entity import Entity +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +async def visible_results( + db: AsyncSession, + organization_id: UUID, + collection: str, + results: list[SearchResult], + registry: SourceRegistryProtocol, +) -> list[SearchResult]: + """Validate request scope and exact publication; never trust index ownership fields.""" + if not results: + return [] + connections = await db.scalars( + select(SourceConnection).where( + SourceConnection.organization_id == organization_id, + SourceConnection.is_authenticated.is_(True), + SourceConnection.readable_collection_id == collection, + ) + ) + scopes = { + str(connection.sync_id): connection + for connection in connections + if connection.sync_id is not None + } + legacy: set[int] = set() + candidates: dict[int, tuple[UUID, ProjectionLocator]] = {} + for index, result in enumerate(results): + metadata = result.airweave_system_metadata + scope = scopes.get(metadata.sync_id) + if scope is None or metadata.source_name != scope.short_name: + continue + canonical = bool(indexed_record_types(scope.short_name, registry)) + try: + locator = ProjectionLocator.parse(metadata.original_entity_id) + except ValueError: + continue + if locator is None: + if not canonical: + legacy.add(index) + else: + candidates[index] = (scope.sync_id, locator) + if not candidates: + return [result for index, result in enumerate(results) if index in legacy] + # Bound predicate size independently from a caller's requested search page. + eligible: dict[tuple[UUID, UUID, int, int, UUID], ExtractionCoverage | None] = {} + candidate_values = list(set(candidates.values())) + for start in range(0, len(candidate_values), 100): + batch = candidate_values[start : start + 100] + rows = await db.execute( + select( + Entity.sync_id, + Entity.id, + Entity.record_revision, + Entity.indexed_pipeline_version, + Entity.indexed_generation, + ProjectionGeneration.extraction_coverage, + ) + .join(Sync, Sync.id == Entity.sync_id) + .join(ProjectionGeneration, ProjectionGeneration.id == Entity.indexed_generation) + .where( + Entity.organization_id == organization_id, + Sync.organization_id == organization_id, + Entity.sync_id.in_({sync_id for sync_id, _ in batch}), + publications_match(locator for _, locator in batch), + ) + ) + for row in rows: + eligible[tuple(row[:-1])] = ( + ExtractionCoverage.model_validate(row[-1]) if row[-1] is not None else None + ) + return [ + result + for index, result in enumerate(results) + if index in legacy or _eligible_part(candidates.get(index), eligible) + ] + + +def _eligible_part( + candidate: tuple[UUID, ProjectionLocator] | None, + eligible: dict[tuple[UUID, UUID, int, int, UUID], ExtractionCoverage | None], +) -> bool: + if candidate is None: + return False + sync_id, locator = candidate + identity = ( + sync_id, + locator.record_id, + locator.revision, + locator.pipeline_version, + locator.generation, + ) + if identity not in eligible: + return False + coverage = eligible[identity] + return coverage is None or any( + part.part_index == locator.part_index and part.outcome == "indexed" + for part in coverage.parts + ) diff --git a/backend/airweave/domains/search/classic/service.py b/backend/airweave/domains/search/classic/service.py index b2f98db5fc..b3402d0aa4 100644 --- a/backend/airweave/domains/search/classic/service.py +++ b/backend/airweave/domains/search/classic/service.py @@ -172,7 +172,9 @@ async def _execute( documents=documents, top_n=request.limit, ) - results = SearchResults(results=[results.results[r.index] for r in reranked]) + results = results.model_copy( + update={"results": [results.results[r.index] for r in reranked]} + ) # 7. Emit event + return duration_ms = int((time.monotonic() - start_time) * 1000) diff --git a/backend/airweave/domains/search/executor.py b/backend/airweave/domains/search/executor.py index 8869e81f11..f94d05847a 100644 --- a/backend/airweave/domains/search/executor.py +++ b/backend/airweave/domains/search/executor.py @@ -19,6 +19,7 @@ from airweave.domains.embedders.protocols import DenseEmbedderProtocol, SparseEmbedderProtocol from airweave.domains.search.adapters.vector_db.protocol import VectorDBProtocol from airweave.domains.search.builders.search_plan import SearchPlanBuilder +from airweave.domains.search.canonical_visibility import visible_results from airweave.domains.search.exceptions import FederatedSearchError from airweave.domains.search.protocols import SearchPlanExecutorProtocol from airweave.domains.search.types import ( @@ -28,6 +29,7 @@ SearchPlan, SearchResults, ) +from airweave.domains.search.types.embeddings import PreparedQueryEmbeddings from airweave.domains.search.types.filters import FilterableField, FilterCondition, FilterOperator from airweave.domains.search.types.results import ( SearchAccessControl, @@ -88,6 +90,8 @@ async def execute( ctx: ApiContext, collection_readable_id: str, user_principal: Optional[str] = None, + indexed_only: bool = False, + prepared_query: PreparedQueryEmbeddings | None = None, ) -> SearchResults: """Execute the full search pipeline including federated sources.""" # 0. Resolve access control principals @@ -99,7 +103,11 @@ async def execute( complete_plan = SearchPlanBuilder.build(plan, user_filter) # 2. Discover federated sources for this collection - federated_sources = await self._discover_federated_sources(db, ctx, collection_readable_id) + federated_sources = ( + [] + if indexed_only + else await self._discover_federated_sources(db, ctx, collection_readable_id) + ) # 3. Adjust limit/offset for RRF pagination (if federated sources exist) original_limit = complete_plan.limit @@ -116,7 +124,9 @@ async def execute( fetch_limit = original_offset + original_limit vector_task = asyncio.create_task( - self._execute_vector_search(complete_plan, collection_id, acl_principals) + self._execute_vector_search( + complete_plan, collection_id, acl_principals, prepared_query + ) ) fed_task = None @@ -130,12 +140,30 @@ async def execute( ) ) - vector_results = await vector_task + engine_results = await vector_task + retrieved = engine_results.results + coverage = { + "engine_partial": engine_results.engine_partial, + "engine_coverage_percent": engine_results.engine_coverage_percent, + } + vector_results = await visible_results( + db, + ctx.organization.id, + collection_readable_id, + retrieved, + self._source_registry, + ) + excluded = len(retrieved) - len(vector_results) fed_results = await fed_task if fed_task else [] # 5. If no federated sources, vector DB already has correct limit/offset if not federated_sources: - return SearchResults(results=vector_results) + return SearchResults( + results=vector_results, + retrieval_incomplete=excluded > 0 or engine_results.engine_partial, + **coverage, + excluded_candidates=excluded, + ) # 6. We over-fetched from vector DB (limit=offset+limit, offset=0) for RRF. # Filter federated results in-memory and merge, or slice vector-only. @@ -147,24 +175,23 @@ async def execute( if fed_filtered: merged = self._merge_with_rrf(vector_results, fed_filtered) - return SearchResults(results=merged[original_offset : original_offset + original_limit]) + return SearchResults( + results=merged[original_offset : original_offset + original_limit], + retrieval_incomplete=excluded > 0 or engine_results.engine_partial, + **coverage, + excluded_candidates=excluded, + ) # All federated results filtered out — slice vector results to original window return SearchResults( - results=vector_results[original_offset : original_offset + original_limit] + results=vector_results[original_offset : original_offset + original_limit], + retrieval_incomplete=excluded > 0 or engine_results.engine_partial, + **coverage, + excluded_candidates=excluded, ) - async def _execute_vector_search( - self, - plan: SearchPlan, - collection_id: str, - acl_principals: Optional[list[str]] = None, - ) -> list[SearchResult]: - """Embed, compile, and execute vector DB search. - - Adapter exceptions (EmbedderError, VectorDBError) propagate directly - to the caller — no wrapping needed since adapters own their error types. - """ + async def prepare_query(self, plan: SearchPlan) -> PreparedQueryEmbeddings: + """Prepare embeddings for this executor only; no source data or authorization cached.""" dense_embeddings = None sparse_embedding = None @@ -173,7 +200,7 @@ async def _execute_vector_search( RetrievalStrategy.HYBRID, ): texts = [plan.query.primary] + list(plan.query.variations) - dense_embeddings = await self._dense_embedder.embed_many(texts) + dense_embeddings = await self._dense_embedder.embed_many(texts, purpose="query") if plan.retrieval_strategy in ( RetrievalStrategy.KEYWORD, @@ -186,13 +213,37 @@ async def _execute_vector_search( sparse_embedding=sparse_embedding, ) + prepared = PreparedQueryEmbeddings( + primary=plan.query.primary, + variations=tuple(plan.query.variations), + strategy=plan.retrieval_strategy, + embeddings=embeddings, + ) + prepared._owner = self + return prepared + + async def _execute_vector_search( + self, + plan: SearchPlan, + collection_id: str, + acl_principals: Optional[list[str]] = None, + prepared_query: PreparedQueryEmbeddings | None = None, + ) -> SearchResults: + """Embed, compile, and execute vector DB search. + + Adapter exceptions (EmbedderError, VectorDBError) propagate directly + to the caller — no wrapping needed since adapters own their error types. + """ + prepared = prepared_query if prepared_query is not None else await self.prepare_query(plan) + embeddings = prepared.require_match(plan, self) + compiled_query = await self._vector_db.compile_query( plan=plan, embeddings=embeddings, collection_id=collection_id, acl_principals=acl_principals, ) - return (await self._vector_db.execute_query(compiled_query)).results + return await self._vector_db.execute_query(compiled_query) # ------------------------------------------------------------------ # Access control resolution @@ -279,7 +330,9 @@ async def _discover_federated_sources( federated_sources: list[BaseSource] = [] for sc in source_connections: entry = self._source_registry.get(sc.short_name) - if not entry.federated_search: + if not entry.federated_search or getattr( + entry.source_class_ref, "canonical_record_types", () + ): continue try: diff --git a/backend/airweave/domains/search/fakes/executor.py b/backend/airweave/domains/search/fakes/executor.py index c49226d85e..a28f3d2daf 100644 --- a/backend/airweave/domains/search/fakes/executor.py +++ b/backend/airweave/domains/search/fakes/executor.py @@ -5,7 +5,8 @@ from typing import Any from airweave.domains.search.protocols import SearchPlanExecutorProtocol -from airweave.domains.search.types import FilterGroup, SearchPlan, SearchResults +from airweave.domains.search.types import FilterGroup, QueryEmbeddings, SearchPlan, SearchResults +from airweave.domains.search.types.embeddings import PreparedQueryEmbeddings class FakeSearchPlanExecutor(SearchPlanExecutorProtocol): @@ -32,6 +33,17 @@ def seed_result_sequence(self, sequence: list[SearchResults | Exception]) -> Non """Seed a sequence of results/errors for successive execute calls.""" self._sequence = list(sequence) + async def prepare_query(self, plan: SearchPlan) -> PreparedQueryEmbeddings: + """Mirror the internal preparation contract without inference.""" + result = PreparedQueryEmbeddings( + primary=plan.query.primary, + variations=tuple(plan.query.variations), + strategy=plan.retrieval_strategy, + embeddings=QueryEmbeddings(), + ) + result._owner = self + return result + async def execute( self, plan: SearchPlan, @@ -41,6 +53,8 @@ async def execute( ctx: Any = None, collection_readable_id: str = "", user_principal: str | None = None, + indexed_only: bool = False, + prepared_query: PreparedQueryEmbeddings | None = None, ) -> SearchResults: """Record the call and return seeded result, or raise seeded error.""" self._calls.append(("execute", plan, user_filter, collection_id)) diff --git a/backend/airweave/domains/search/owned.py b/backend/airweave/domains/search/owned.py new file mode 100644 index 0000000000..64944ae396 --- /dev/null +++ b/backend/airweave/domains/search/owned.py @@ -0,0 +1,740 @@ +"""Original-record indexed retrieval using the existing executor and SQL authority.""" + +import asyncio +import math +import re +from collections import defaultdict +from uuid import UUID + +from fastapi import HTTPException +from pydantic import AwareDatetime, BaseModel, ConfigDict +from sqlalchemy import and_, case, func, or_, select, tuple_ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from airweave.api.context import ApiContext +from airweave.core.protocols.reranker import RerankerProtocol, RerankerResult +from airweave.core.protocols.tokenizer import TokenizerProtocol +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.projection_models import ProjectionLocator +from airweave.domains.entities.canonical.projection_store import publications_match +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.search_metadata import ( + SEARCH_METADATA_PIPELINE_VERSION, + epoch_microseconds, +) +from airweave.domains.entities.canonical.source import indexed_record_types +from airweave.domains.entities.canonical.store import content_is_available +from airweave.domains.search.owned_models import ( + OwnedRanking, + OwnedSearchCoverage, + OwnedSearchGroup, + OwnedSearchHit, + OwnedSearchMatch, + OwnedSearchRequest, + OwnedSearchResponse, +) +from airweave.domains.search.protocols import SearchPlanExecutorProtocol +from airweave.domains.search.types import FilterCondition, FilterGroup, SearchPlan, SearchQuery +from airweave.domains.search.types.results import SearchResult, SearchResults +from airweave.domains.sources.protocols import SourceRegistryProtocol +from airweave.models.collection import Collection +from airweave.models.entity import Entity +from airweave.models.projection_generation import ProjectionGeneration +from airweave.models.source_connection import SourceConnection +from airweave.models.sync import Sync + + +class _SourceScope(BaseModel): + """Detached authorization/index snapshot; revalidated before returning any hit.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + sync_id: UUID + short_name: str + readable_collection_id: str + collection_id: UUID + index_pipeline_version: int + + +class _EnrichmentRecord(BaseModel): + """Detached search-card fields; original payloads and blob manifests stay in the store.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + sync_id: UUID + record_revision: int + indexed_generation: UUID + indexed_pipeline_version: int + entity_definition_short_name: str + native_id: str + container_id: str | None + parent_record_type: str | None + parent_native_id: str | None + parent_container_id: str | None + observed_at: AwareDatetime + source_created_at: AwareDatetime | None + source_updated_at: AwareDatetime | None + completeness: str + email_thread_id: str | None + + +class OwnedSearchService: + """Canonical retrieval and optional shared ranking, without source provider calls.""" + + def __init__( + self, + executor: SearchPlanExecutorProtocol, + registry: SourceRegistryProtocol, + *, + reranker: RerankerProtocol | None = None, + tokenizer: TokenizerProtocol | None = None, + ): + """Reuse the executor and registry without another retrieval stack.""" + self._executor = executor + self._registry = registry + self._reranker = reranker + self._tokenizer = tokenizer + + async def search( + self, + sessions: async_sessionmaker[AsyncSession], + ctx: ApiContext, + request: OwnedSearchRequest, + ) -> OwnedSearchResponse: + """Resolve exact authorized scopes before any embedding/index request.""" + async with sessions() as db: + scopes, groups = await self._resolve_scopes(db, ctx, request) + if self._filtered(request) and any( + scope.index_pipeline_version < SEARCH_METADATA_PIPELINE_VERSION + for scope in scopes.values() + ): + raise HTTPException( + 409, + { + "code": "reindex_required", + "message": "Selected sources need canonical search metadata re-projection", + }, + ) + scope_snapshot = self._scope_identity(scopes) + hits, scores, matched_text, exclusions, postfiltered = {}, {}, {}, 0, 0 + engine_partial, full = False, False + plan = SearchPlan( + query=SearchQuery(primary=request.query), + limit=200, + offset=0, + retrieval_strategy=request.mode, + ) + prepared_query = await self._executor.prepare_query(plan) if groups else None + for (collection_id, readable_id), sync_ids in groups.items(): + # A new session has no checked-out connection before indexed retrieval: + # this path has neither principal ACL discovery nor provider federation. + async with sessions() as db: + results = await self._executor.execute( + plan=plan, + prepared_query=prepared_query, + user_filter=[FilterGroup(conditions=self._prefilters(request, sync_ids))], + collection_id=str(collection_id), + db=db, + ctx=ctx, + collection_readable_id=readable_id, + indexed_only=True, + ) + group_hits, group_scores, group_text, rejected, filtered = await self._enrich( + db, ctx, request, sync_ids, scopes, results + ) + engine_partial |= results.engine_partial + exclusions += results.excluded_candidates + full |= len(results.results) + results.excluded_candidates >= 200 + hits.update(group_hits) + scores.update(group_scores) + matched_text.update(group_text) + exclusions += rejected + postfiltered += filtered + # Revalidate the whole union before any remote text disclosure: earlier + # collections may have changed while later collections were retrieved. + async with sessions() as db: + fresh_scopes, _ = await self._resolve_scopes(db, ctx, request) + if self._scope_identity(fresh_scopes) != scope_snapshot: + raise HTTPException(404, "Requested indexed sources changed during retrieval") + eligible = await self._final_publications(db, ctx, request, scores, scope_snapshot) + exclusions += len(hits.keys() - eligible) + candidates = sorted(hits.keys() & eligible, key=lambda key: (-scores[key][0], str(key))) + ranked, ranking = await self._rank(request.query, candidates, hits, matched_text) + # No SQL connection is held across the reranker await. No network I/O + # follows this final authorization/publication read boundary. + async with sessions() as db: + sources = await self._coverage(db, ctx, request) + fresh_scopes, _ = await self._resolve_scopes(db, ctx, request) + if self._scope_identity(fresh_scopes) != scope_snapshot: + raise HTTPException(404, "Requested indexed sources changed during retrieval") + final = await self._final_publications(db, ctx, request, scores, scope_snapshot) + exclusions += len(set(candidates) - final) + ranked = [key for key in ranked if key in final] + grouped = self._group_hits([hits[key] for key in ranked]) + return OwnedSearchResponse( + items=tuple(grouped[: request.limit]), + ranking=ranking, + sources=sources, + candidate_window_full=full, + engine_partial=engine_partial, + excluded_candidates=exclusions, + postfilter_excluded=postfiltered, + retrieval_incomplete=ranking.shortlist_truncated + or engine_partial + or full + or exclusions > 0 + or postfiltered > 0 + or len(grouped) > request.limit + or any( + row.pending_records + or row.partially_indexed_records + or row.extraction_unavailable_records + or row.extraction_unknown_records + for row in sources + ), + ) + + @staticmethod + def _conversation(row: _EnrichmentRecord, hit: OwnedSearchHit) -> OwnedSearchGroup | None: + """Derive membership only from admitted canonical parent or validated Gmail identity.""" + if hit.provider == "gmail" and hit.email_thread_id is not None: + return OwnedSearchGroup( + kind="email_thread", native_id=hit.email_thread_id, matched_records=1 + ) + if hit.provider != "almanac": + return None + if ( + row.entity_definition_short_name == "session" + and row.container_id is None + and row.parent_record_type is None + and row.parent_native_id is None + and row.parent_container_id is None + ): + return OwnedSearchGroup(kind="session", native_id=row.native_id, matched_records=1) + if ( + row.entity_definition_short_name == "message" + and row.parent_record_type == "session" + and row.parent_native_id + and row.parent_container_id is None + and row.container_id == row.parent_native_id + ): + return OwnedSearchGroup( + kind="session", native_id=row.parent_native_id, matched_records=1 + ) + return None + + @staticmethod + def _group_hits(ranked: list[OwnedSearchHit]) -> list[OwnedSearchHit]: + """Group final eligible matches in rank order, preserving each original.""" + output: list[OwnedSearchHit] = [] + positions: dict[tuple[UUID, str, str, str], int] = {} + for hit in ranked: + group = hit.group + if group is None: + output.append(hit) + continue + key = (hit.sync_id, hit.provider, group.kind, group.native_id) + if key not in positions: + positions[key] = len(output) + output.append( + hit.model_copy( + update={ + "group": group.model_copy( + update={"matched_records": 1, "additional_matches": ()} + ) + } + ) + ) + continue + index = positions[key] + representative = output[index] + previous = representative.group + assert previous is not None + additional = previous.additional_matches + if len(additional) < 3: + exact = OwnedSearchMatch.model_validate( + hit.model_dump(exclude={"group"}, round_trip=True) + ) + additional = ( + *additional, + exact.model_copy(update={"excerpts": exact.excerpts[:1]}), + ) + output[index] = representative.model_copy( + update={ + "group": previous.model_copy( + update={ + "matched_records": previous.matched_records + 1, + "additional_matches": additional, + } + ) + } + ) + return output + + async def _rank( + self, + query: str, + candidates: list[UUID], + hits: dict[UUID, OwnedSearchHit], + matched_text: dict[UUID, str], + ) -> tuple[list[UUID], OwnedRanking]: + """One model call over a bounded mixed shortlist; failures keep retrieval order.""" + shortlist = candidates[:200] + ranking = OwnedRanking( + candidates_considered=len(candidates), + shortlisted_candidates=len(shortlist), + shortlist_truncated=len(candidates) > len(shortlist), + ) + if not shortlist: + return [], ranking.model_copy(update={"fallback_reason": None}) + if self._reranker is None or self._tokenizer is None: + return shortlist, ranking + documents, truncated = [], 0 + for key in shortlist: + title = hits[key].title[:256] + text = f"{title}\n\n{matched_text[key]}" + document = self._bounded_document(text) + documents.append(document) + truncated += int(document != text or title != hits[key].title) + ranking = ranking.model_copy(update={"input_truncated_documents": truncated}) + try: + async with asyncio.timeout(10): + results = await self._reranker.rerank(query, documents, top_n=len(documents)) + except TimeoutError: + return shortlist, ranking.model_copy(update={"fallback_reason": "timeout"}) + except Exception: + # Never log provider errors: SDK messages can contain submitted text. + return shortlist, ranking.model_copy(update={"fallback_reason": "provider_error"}) + try: + invalid = ( + not isinstance(results, list) + or len(results) != len(shortlist) + or any( + not isinstance(result, RerankerResult) + or type(result.index) is not int + or not 0 <= result.index < len(shortlist) + or type(result.relevance_score) not in (int, float) + or not math.isfinite(result.relevance_score) + for result in results + ) + or {result.index for result in results} != set(range(len(shortlist))) + ) + except (TypeError, ValueError, OverflowError): + invalid = True + if invalid: + return shortlist, ranking.model_copy(update={"fallback_reason": "invalid_output"}) + ordered = sorted(results, key=lambda result: (-result.relevance_score, result.index)) + return [shortlist[result.index] for result in ordered], ranking.model_copy( + update={ + "method": "shared_rerank", + "fallback_reason": None, + "candidates_reranked": len(shortlist), + } + ) + + def _bounded_document(self, text: str) -> str: + """Bound local tokenizer counts; provider tokenization and billing may differ.""" + if self._tokenizer.count_tokens(text) <= 2048: + return text + accepted, rejected = 0, len(text) + while rejected - accepted > 1: + midpoint = (accepted + rejected) // 2 + if self._tokenizer.count_tokens(text[:midpoint]) <= 2048: + accepted = midpoint + else: + rejected = midpoint + # Token count need not be monotonic in characters. The accepted prefix + # was measured explicitly, so this remains a safe bound, not a max-length claim. + return text[:accepted] + + @staticmethod + def _filtered(request: OwnedSearchRequest) -> bool: + return bool( + request.record_types + or request.created_after + or request.created_before + or request.updated_after + or request.updated_before + ) + + @staticmethod + def _prefilters(request: OwnedSearchRequest, sync_ids: list[UUID]) -> list[FilterCondition]: + conditions = [ + FilterCondition( + field="airweave_system_metadata.sync_id", + operator="in", + value=[str(item) for item in sync_ids], + ) + ] + if request.record_types: + conditions.append( + FilterCondition( + field="airweave_system_metadata.canonical_record_type", + operator="in", + value=list(request.record_types), + ) + ) + for name, after, before in ( + ("created", request.created_after, request.created_before), + ("updated", request.updated_after, request.updated_before), + ): + if after is not None or before is not None: + conditions.append( + FilterCondition( + field=f"airweave_system_metadata.source_{name}_known", + operator="equals", + value=1, + ) + ) + for boundary, operator in ((after, "greater_than_or_equal"), (before, "less_than")): + if boundary is not None: + conditions.append( + FilterCondition( + field=f"airweave_system_metadata.source_{name}_us", + operator=operator, + value=epoch_microseconds(boundary), + ) + ) + return conditions + + async def _resolve_scopes( + self, db: AsyncSession, ctx: ApiContext, request: OwnedSearchRequest + ) -> tuple[dict[UUID, _SourceScope], dict[tuple[UUID, str], list[UUID]]]: + rows = ( + ( + await db.execute( + select( + SourceConnection.id, + SourceConnection.sync_id, + SourceConnection.short_name, + SourceConnection.readable_collection_id, + Collection.id.label("collection_id"), + Sync.index_pipeline_version, + ) + .join( + Collection, + and_( + Collection.readable_id == SourceConnection.readable_collection_id, + Collection.organization_id == ctx.organization.id, + ), + ) + .join( + Sync, + and_( + Sync.id == SourceConnection.sync_id, + Sync.organization_id == ctx.organization.id, + ), + ) + .where( + SourceConnection.organization_id == ctx.organization.id, + SourceConnection.is_authenticated.is_(True), + SourceConnection.sync_id.in_(request.sync_ids), + ) + ) + ) + .mappings() + .all() + ) + snapshots = [_SourceScope.model_validate(row) for row in rows] + if len(snapshots) != len(request.sync_ids) or {row.sync_id for row in snapshots} != set( + request.sync_ids + ): + raise HTTPException(404, "Requested indexed sources are unavailable") + scopes = {row.sync_id: row for row in snapshots} + allowed_types = set() + groups = defaultdict(list) + for connection in snapshots: + types = indexed_record_types(connection.short_name, self._registry) + if not types: + raise HTTPException(422, "Source does not support owned indexed records") + allowed_types.update(types) + groups[(connection.collection_id, connection.readable_collection_id)].append( + connection.sync_id + ) + if set(request.record_types) - allowed_types: + raise HTTPException(422, "Record type is unsupported by selected sources") + return scopes, groups + + async def _enrich( + self, + db: AsyncSession, + ctx: ApiContext, + request: OwnedSearchRequest, + sync_ids: list[UUID], + scopes: dict[UUID, _SourceScope], + results: SearchResults, + ) -> tuple[ + dict[UUID, OwnedSearchHit], + dict[UUID, tuple[float, ProjectionLocator]], + dict[UUID, str], + int, + int, + ]: + locators = [value for result in results.results if (value := self._locator(result))] + if not locators: + return {}, {}, {}, len(results.results), 0 + hits, scores, matched_text, exclusions, postfiltered = {}, {}, {}, 0, 0 + # Recheck current publication while obtaining native identity. Never enrich + # from an unvalidated cached hit after a concurrent capture/permission change. + rows = await db.execute( + select( + Entity.id, + Entity.sync_id, + Entity.record_revision, + Entity.indexed_generation, + Entity.indexed_pipeline_version, + Entity.entity_definition_short_name, + Entity.native_id, + Entity.container_id, + Entity.parent_record_type, + Entity.parent_native_id, + Entity.parent_container_id, + Entity.observed_at, + Entity.source_created_at, + Entity.source_updated_at, + Entity.completeness, + case( + ( + func.jsonb_typeof(Entity.source_payload["threadId"]) == "string", + Entity.source_payload["threadId"].astext, + ), + else_=None, + ).label("email_thread_id"), + ) + .join(Sync, Sync.id == Entity.sync_id) + .where( + Entity.organization_id == ctx.organization.id, + Sync.organization_id == ctx.organization.id, + Entity.sync_id.in_(sync_ids), + publications_match(locators), + ) + ) + records = [_EnrichmentRecord.model_validate(row) for row in rows.mappings()] + by_id = {record.id: record for record in records} + extraction_rows = await db.execute( + select(ProjectionGeneration.id, ProjectionGeneration.extraction_coverage).where( + ProjectionGeneration.id.in_([r.indexed_generation for r in records]) + ) + ) + extraction = { + generation: ExtractionCoverage.model_validate(raw) if raw is not None else None + for generation, raw in extraction_rows + } + for rank, result in enumerate(results.results, 1): + locator = self._locator(result) + row = by_id.get(locator.record_id) if locator else None + if ( + row is None + or row.record_revision != locator.revision + or row.indexed_generation != locator.generation + or row.indexed_pipeline_version != locator.pipeline_version + or str(row.sync_id) != result.airweave_system_metadata.sync_id + or scopes[row.sync_id].short_name != result.airweave_system_metadata.source_name + ): + exclusions += 1 + continue + coverage = extraction.get(row.indexed_generation) + if coverage is not None and not any( + part.part_index == locator.part_index and part.outcome == "indexed" + for part in coverage.parts + ): + exclusions += 1 + continue + if not self._matches(row, request): + postfiltered += 1 + continue + if row.id not in hits: + hits[row.id] = OwnedSearchHit( + record_id=row.id, + revision=row.record_revision, + sync_id=row.sync_id, + source_connection_id=scopes[row.sync_id].id, + provider=scopes[row.sync_id].short_name, + identity=RecordIdentity( + record_type=row.entity_definition_short_name, + native_id=row.native_id, + container_id=row.container_id, + ), + title=result.name, + excerpts=(), + observed_at=row.observed_at, + source_created_at=row.source_created_at, + source_updated_at=row.source_updated_at, + completeness=row.completeness, + extraction=extraction.get(row.indexed_generation), + email_thread_id=( + thread_id + if scopes[row.sync_id].short_name == "gmail" + and row.entity_definition_short_name == "message" + and (thread_id := row.email_thread_id) is not None + and re.fullmatch(r"[A-Za-z0-9_-]{1,512}", thread_id) + else None + ), + ) + hits[row.id].group = self._conversation(row, hits[row.id]) + scores[row.id] = (1 / (60 + rank), locator) + matched_text[row.id] = result.textual_representation + # Dynamic summaries are keyword fragments, not semantic explanations. + # No-mark and older-schema responses keep the existing chunk fallback. + snippet = result.query_snippet + if ( + request.mode.value != "semantic" + and snippet + and "" in snippet + and "" in snippet + ): + # Remove only Vespa's presentation delimiters. All other markup is + # ordinary untrusted text, never parsed/rendered as HTML here. + excerpt = ( + snippet.replace("", "") + .replace("", "") + .replace("", " … ") + .strip() + or result.textual_representation + )[:2000] + else: + excerpt = result.textual_representation[:2000] + current = hits[row.id] + if excerpt and excerpt not in current.excerpts and len(current.excerpts) < 3: + hits[row.id] = current.model_copy(update={"excerpts": (*current.excerpts, excerpt)}) + return hits, scores, matched_text, exclusions, postfiltered + + @staticmethod + def _scope_identity(scopes: dict[UUID, _SourceScope]) -> set[tuple]: + return { + ( + sync, + row.id, + row.short_name, + row.readable_collection_id, + row.collection_id, + row.index_pipeline_version, + ) + for sync, row in scopes.items() + } + + @staticmethod + async def _final_publications( + db: AsyncSession, + ctx: ApiContext, + request: OwnedSearchRequest, + scores: dict[UUID, tuple[float, ProjectionLocator]], + scope_snapshot: set[tuple], + ) -> set[UUID]: + # Later collection retrieval may outlive an edit/revocation of earlier hits. + # Recheck the exact publication, including parent visibility, at the final + # SQL read boundary. No network I/O occurs after this authorization check. + if not scores: + return set() + # One statement snapshots all candidates, including current authentication. + # The 20*200 bound keeps these tuple parameters below PostgreSQL's limit. + return set( + await db.scalars( + select(Entity.id) + .join(Sync, Sync.id == Entity.sync_id) + .join( + SourceConnection, + and_( + SourceConnection.sync_id == Sync.id, + SourceConnection.organization_id == ctx.organization.id, + SourceConnection.is_authenticated.is_(True), + ), + ) + .join( + Collection, + and_( + Collection.readable_id == SourceConnection.readable_collection_id, + Collection.organization_id == ctx.organization.id, + ), + ) + .where( + Entity.organization_id == ctx.organization.id, + Sync.organization_id == ctx.organization.id, + Entity.sync_id.in_(request.sync_ids), + tuple_( + SourceConnection.sync_id, + SourceConnection.id, + SourceConnection.short_name, + SourceConnection.readable_collection_id, + Collection.id, + Sync.index_pipeline_version, + ).in_(list(scope_snapshot)), + publications_match(locator for _, locator in scores.values()), + ) + ) + ) + + @staticmethod + def _locator(result: SearchResult) -> ProjectionLocator | None: + try: + return ProjectionLocator.parse(result.airweave_system_metadata.original_entity_id) + except ValueError: + return None + + @staticmethod + def _matches(row: _EnrichmentRecord, request: OwnedSearchRequest) -> bool: + if request.record_types and row.entity_definition_short_name not in request.record_types: + return False + for value, after, before in ( + (row.source_created_at, request.created_after, request.created_before), + (row.source_updated_at, request.updated_after, request.updated_before), + ): + if (after is not None and (value is None or value < after)) or ( + before is not None and (value is None or value >= before) + ): + return False + return True + + @staticmethod + async def _coverage( + db: AsyncSession, ctx: ApiContext, request: OwnedSearchRequest + ) -> tuple[OwnedSearchCoverage, ...]: + pending = or_( + Entity.indexed_revision.is_distinct_from(Entity.record_revision), + Entity.indexed_pipeline_version.is_distinct_from(Sync.index_pipeline_version), + Entity.indexed_generation.is_(None), + ) + has_indexed = ProjectionGeneration.extraction_coverage.contains( + {"parts": [{"outcome": "indexed"}]} + ) + has_omitted = ProjectionGeneration.extraction_coverage.contains( + {"parts": [{"outcome": "unsupported"}]} + ) | ProjectionGeneration.extraction_coverage.contains( + {"parts": [{"outcome": "unavailable_original"}]} + ) + rows = await db.execute( + select( + Entity.sync_id, + func.count(), + func.count().filter(pending), + func.count().filter(~pending & has_indexed & has_omitted), + func.count().filter(~pending & ~has_indexed & has_omitted), + func.count().filter(~pending & ProjectionGeneration.extraction_coverage.is_(None)), + ) + .join(Sync, Sync.id == Entity.sync_id) + .outerjoin(ProjectionGeneration, ProjectionGeneration.id == Entity.indexed_generation) + .where( + Entity.organization_id == ctx.organization.id, + Sync.organization_id == ctx.organization.id, + Entity.sync_id.in_(request.sync_ids), + Entity.record_revision > 0, + Entity.deleted_at.is_(None), + content_is_available(), + ) + .group_by(Entity.sync_id) + ) + counts = {row[0]: tuple(row[1:]) for row in rows} + captures = await capture_coverage(db, ctx.organization.id, tuple(request.sync_ids)) + return tuple( + OwnedSearchCoverage( + sync_id=sync, + capture=captures.get(sync), + active_records=counts.get(sync, (0, 0))[0], + pending_records=counts.get(sync, (0, 0))[1], + partially_indexed_records=counts.get(sync, (0, 0, 0, 0, 0))[2], + extraction_unavailable_records=counts.get(sync, (0, 0, 0, 0, 0))[3], + extraction_unknown_records=counts.get(sync, (0, 0, 0, 0, 0))[4], + ) + for sync in request.sync_ids + ) diff --git a/backend/airweave/domains/search/owned_models.py b/backend/airweave/domains/search/owned_models.py new file mode 100644 index 0000000000..5800a99c4f --- /dev/null +++ b/backend/airweave/domains/search/owned_models.py @@ -0,0 +1,123 @@ +"""Bounded original-record retrieval; unsupported controls fail validation.""" + +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +from airweave.domains.entities.canonical.coverage_models import CaptureCoverage +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.search.retrieval_strategy import RetrievalStrategy + + +class OwnedSearchRequest(BaseModel): + """Source IDs are organization-scoped; the product separately owns account authorization.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + query: str = Field(min_length=1, max_length=4000) + sync_ids: tuple[UUID, ...] = Field(min_length=1, max_length=20) + mode: RetrievalStrategy = RetrievalStrategy.HYBRID + record_types: tuple[str, ...] = Field(default=(), max_length=20) + created_after: AwareDatetime | None = None + created_before: AwareDatetime | None = None + updated_after: AwareDatetime | None = None + updated_before: AwareDatetime | None = None + limit: int = Field(default=20, ge=1, le=200) + + @model_validator(mode="after") + def validate_scope(self): + """Reject ambiguous requests rather than silently correcting them.""" + if not self.query.strip() or len(set(self.sync_ids)) != len(self.sync_ids): + raise ValueError("Query must be nonblank and source IDs unique") + if any(not item.strip() for item in self.record_types): + raise ValueError("Record types must be nonblank") + for after, before in ( + (self.created_after, self.created_before), + (self.updated_after, self.updated_before), + ): + if after is not None and before is not None and after >= before: + raise ValueError("Time interval must have after before before") + return self + + +class OwnedSearchMatch(BaseModel): + """Canonical identity plus excerpts; no native payload or generation IDs.""" + + record_id: UUID + revision: int + sync_id: UUID + source_connection_id: UUID + provider: str + identity: RecordIdentity + title: str + excerpts: tuple[str, ...] + email_thread_id: str | None = Field(default=None, max_length=512, pattern=r"^[A-Za-z0-9_-]+$") + observed_at: AwareDatetime + source_created_at: AwareDatetime | None + source_updated_at: AwareDatetime | None + completeness: Literal["complete", "partial", "metadata_only"] + extraction: ExtractionCoverage | None = None + + +class OwnedSearchGroup(BaseModel): + """Observed eligible shortlist members, never a complete conversation count.""" + + kind: Literal["session", "email_thread"] + native_id: str + matched_records: int = Field(ge=1) + additional_matches: tuple[OwnedSearchMatch, ...] = Field(default=(), max_length=3) + + +class OwnedSearchHit(OwnedSearchMatch): + """An exact representative match, optionally with other conversation matches.""" + + group: OwnedSearchGroup | None = None + + +class OwnedSearchCoverage(BaseModel): + """Counts are source-store state, never total matches for this query.""" + + sync_id: UUID + active_records: int + capture: CaptureCoverage | None = None + pending_records: int + partially_indexed_records: int = 0 + extraction_unavailable_records: int = 0 + extraction_unknown_records: int = 0 + + +class OwnedRanking(BaseModel): + """Ranking applies only to this request's bounded canonical-source candidates.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + method: Literal["shared_rerank", "retrieval_rank"] = "retrieval_rank" + fallback_reason: ( + Literal["unconfigured", "timeout", "provider_error", "invalid_output"] | None + ) = "unconfigured" + candidates_considered: int = Field(default=0, ge=0) + candidates_reranked: int = Field(default=0, ge=0) + shortlisted_candidates: int = Field(default=0, ge=0, le=200) + shortlist_limit: Literal[200] = 200 + document_token_limit: Literal[2048] = 2048 + token_count_basis: Literal["local_tokenizer"] = "local_tokenizer" + timeout_seconds: Literal[10] = 10 + input_truncated_documents: int = Field(default=0, ge=0) + shortlist_truncated: bool = False + + +class OwnedSearchResponse(BaseModel): + """Top results from bounded live candidate windows, without traversal promises.""" + + items: tuple[OwnedSearchHit, ...] + ranking: OwnedRanking = Field(default_factory=OwnedRanking) + sources: tuple[OwnedSearchCoverage, ...] + consistency: Literal["live"] = "live" + order: Literal["relevance"] = "relevance" + coverage: Literal["bounded_candidates"] = "bounded_candidates" + candidate_limit_per_collection: int = 200 + candidate_window_full: bool + engine_partial: bool + excluded_candidates: int + postfilter_excluded: int + retrieval_incomplete: bool diff --git a/backend/airweave/domains/search/protocols.py b/backend/airweave/domains/search/protocols.py index f8403372b4..7e6f558743 100644 --- a/backend/airweave/domains/search/protocols.py +++ b/backend/airweave/domains/search/protocols.py @@ -13,6 +13,7 @@ SearchPlan, SearchResults, ) +from airweave.domains.search.types.embeddings import PreparedQueryEmbeddings if TYPE_CHECKING: from airweave.schemas.search_v2 import ( @@ -33,6 +34,10 @@ class SearchPlanExecutorProtocol(Protocol): Used by all three tiers (instant, classic, agentic). """ + async def prepare_query(self, plan: SearchPlan) -> PreparedQueryEmbeddings: + """Compute pure query work once for serial collection searches in one request.""" + ... + async def execute( self, plan: SearchPlan, @@ -42,6 +47,8 @@ async def execute( ctx: ApiContext, collection_readable_id: str, user_principal: Optional[str] = None, + indexed_only: bool = False, + prepared_query: PreparedQueryEmbeddings | None = None, ) -> SearchResults: """Execute a search plan and return results.""" ... diff --git a/backend/airweave/domains/search/retrieval_strategy.py b/backend/airweave/domains/search/retrieval_strategy.py new file mode 100644 index 0000000000..4a08ea1b90 --- /dev/null +++ b/backend/airweave/domains/search/retrieval_strategy.py @@ -0,0 +1,11 @@ +"""Public retrieval mode values, independent of execution and backend settings.""" + +from enum import Enum + + +class RetrievalStrategy(str, Enum): + """Supported retrieval strategies.""" + + SEMANTIC = "semantic" + KEYWORD = "keyword" + HYBRID = "hybrid" diff --git a/backend/airweave/domains/search/tests/test_collection_metadata_builder.py b/backend/airweave/domains/search/tests/test_collection_metadata_builder.py index de6a307abb..81b56be22e 100644 --- a/backend/airweave/domains/search/tests/test_collection_metadata_builder.py +++ b/backend/airweave/domains/search/tests/test_collection_metadata_builder.py @@ -23,6 +23,7 @@ def _make_source_connection(short_name: str = "slack", sync_id=None): """Create a mock source connection.""" sc = MagicMock() sc.short_name = short_name + sc.is_authenticated = True sc.sync_id = sync_id or uuid4() return sc @@ -178,3 +179,33 @@ def test_get_source_description_unknown(self, builder: CollectionMetadataBuilder """Unknown sources raise ValueError.""" with pytest.raises(ValueError, match="No description found"): builder._get_source_description("unknown_source_xyz") + + +async def test_wispr_collection_builds_metadata_for_search_planning(builder): + """An owned Wispr connection must not abort classic/agentic prompt construction.""" + from airweave.platform.entities.wispr import WisprMeetingEntity + + builder._collection_repo.get_by_readable_id.return_value = _make_collection() + builder._sc_repo.get_by_collection_ids.return_value = [_make_source_connection("wispr")] + builder._entity_definition_registry.list_for_source.return_value = [ + _make_entity_definition_entry( + "WisprMeetingEntity", "WisprMeetingEntity", WisprMeetingEntity.model_json_schema() + ) + ] + builder._entity_count_repo.get_counts_per_sync_and_type.return_value = [] + builder._source_registry.get.return_value.federated_search = False + result = await builder.build(AsyncMock(), MagicMock(), "test-collection") + assert result.sources[0].short_name == "wispr" + assert "partial" in result.sources[0].description + assert result.sources[0].entity_types[0].fields + + +async def test_unauthenticated_source_is_omitted_not_reported_empty(builder): + builder._collection_repo.get_by_readable_id.return_value = _make_collection() + source = _make_source_connection("wispr") + source.is_authenticated = False + builder._sc_repo.get_by_collection_ids.return_value = [source] + metadata = await builder.build(AsyncMock(), MagicMock(), "test-collection") + assert metadata.sources == [] + builder._entity_definition_registry.list_for_source.assert_not_called() + builder._entity_count_repo.get_counts_per_sync_and_type.assert_not_called() diff --git a/backend/airweave/domains/search/tests/test_executor.py b/backend/airweave/domains/search/tests/test_executor.py index 1768da0777..b9d521f747 100644 --- a/backend/airweave/domains/search/tests/test_executor.py +++ b/backend/airweave/domains/search/tests/test_executor.py @@ -48,6 +48,17 @@ from airweave.platform.entities._base import AirweaveSystemMetadata, BaseEntity, Breadcrumb from airweave.platform.entities.slack import SlackMessageEntity + +@pytest.fixture(autouse=True) +def isolated_publication_gate(monkeypatch): + """Pipeline unit tests use fake retrieval; real SQL eligibility has PostgreSQL tests.""" + + async def passthrough(db, organization_id, collection, results, registry): + return results + + monkeypatch.setattr("airweave.domains.search.executor.visible_results", passthrough) + + # ── Helpers ────────────────────────────────────────────────────────── @@ -483,9 +494,7 @@ def test_source_name_filter_keeps_matching(self): def test_entity_type_filter(self): slack = _make_federated_result(entity_id="f1", entity_type="SlackMessageEntity") github = _make_search_result(entity_id="g1", entity_type="GitHubPREntity") - filters = [ - _make_filter("airweave_system_metadata.entity_type", "equals", "GitHubPREntity") - ] + filters = [_make_filter("airweave_system_metadata.entity_type", "equals", "GitHubPREntity")] filtered = SearchPlanExecutor._apply_filters_in_memory([slack, github], filters) assert len(filtered) == 1 assert filtered[0].entity_id == "g1" @@ -494,9 +503,7 @@ def test_sync_id_filter_excludes_federated(self): """Federated results have sync_id=None, so filtering on sync_id excludes them.""" fed = _make_federated_result(entity_id="f1") synced = _make_search_result(entity_id="s1", sync_id="some-sync-id") - filters = [ - _make_filter("airweave_system_metadata.sync_id", "equals", "some-sync-id") - ] + filters = [_make_filter("airweave_system_metadata.sync_id", "equals", "some-sync-id")] filtered = SearchPlanExecutor._apply_filters_in_memory([fed, synced], filters) assert len(filtered) == 1 assert filtered[0].entity_id == "s1" @@ -533,19 +540,13 @@ def test_or_across_groups(self): _make_filter("airweave_system_metadata.source_name", "equals", "slack"), _make_filter("airweave_system_metadata.source_name", "equals", "github"), ] - filtered = SearchPlanExecutor._apply_filters_in_memory( - [slack, github, notion], filters - ) + filtered = SearchPlanExecutor._apply_filters_in_memory([slack, github, notion], filters) assert len(filtered) == 2 assert {r.entity_id for r in filtered} == {"f1", "g1"} def test_date_filter(self): - old = _make_federated_result( - entity_id="f1", created_at=datetime(2025, 1, 1) - ) - new = _make_federated_result( - entity_id="f2", created_at=datetime(2026, 3, 1) - ) + old = _make_federated_result(entity_id="f1", created_at=datetime(2025, 1, 1)) + new = _make_federated_result(entity_id="f2", created_at=datetime(2026, 3, 1)) filters = [_make_filter("created_at", "greater_than", "2026-01-01T00:00:00")] filtered = SearchPlanExecutor._apply_filters_in_memory([old, new], filters) assert len(filtered) == 1 @@ -760,9 +761,7 @@ async def test_filter_source_name_github_excludes_slack(self): source_lifecycle=source_lifecycle, ) - user_filter = [ - _make_filter("airweave_system_metadata.source_name", "equals", "github") - ] + user_filter = [_make_filter("airweave_system_metadata.source_name", "equals", "github")] results = await executor.execute( plan=_make_plan(), @@ -774,9 +773,7 @@ async def test_filter_source_name_github_excludes_slack(self): ) # Only GitHub results should remain — Slack filtered out in-memory - assert all( - r.airweave_system_metadata.source_name == "github" for r in results.results - ) + assert all(r.airweave_system_metadata.source_name == "github" for r in results.results) @pytest.mark.asyncio async def test_filter_source_name_slack_returns_federated_only(self): @@ -805,9 +802,7 @@ async def test_filter_source_name_slack_returns_federated_only(self): source_lifecycle=source_lifecycle, ) - user_filter = [ - _make_filter("airweave_system_metadata.source_name", "equals", "slack") - ] + user_filter = [_make_filter("airweave_system_metadata.source_name", "equals", "slack")] results = await executor.execute( plan=_make_plan(), @@ -1082,9 +1077,7 @@ class TestExecutorFilterEdgeCases: def test_empty_filter_groups_passes_all(self): """filter_groups=[] → all results pass through unfiltered.""" - results = [ - _make_federated_result(entity_id=f"f{i}") for i in range(5) - ] + results = [_make_federated_result(entity_id=f"f{i}") for i in range(5)] filtered = SearchPlanExecutor._apply_filters_in_memory(results, []) assert len(filtered) == 5 assert filtered == results @@ -1151,9 +1144,7 @@ async def search(self, query: str, limit: int) -> list: source = _TransientSource() executor = _build_executor() - results = await executor._search_single_source( - source, "test", 10, _make_ctx() - ) + results = await executor._search_single_source(source, "test", 10, _make_ctx()) assert results == [] assert source._attempt == 2 @@ -1201,3 +1192,105 @@ async def search(self, query: str, limit: int) -> list: # 1 initial + 2 retries = 3 total assert source._attempt == 3 + + +@pytest.mark.asyncio +async def test_executor_returns_only_publication_gate_survivors(monkeypatch): + """Classic/instant callers never receive a rejected candidate for reranking.""" + vector_db = FakeVectorDB() + fresh, stale = _make_search_result("fresh"), _make_search_result("stale") + vector_db.seed_results(SearchResults(results=[fresh, stale])) + gate = AsyncMock(return_value=[fresh]) + monkeypatch.setattr("airweave.domains.search.executor.visible_results", gate) + executor = _build_executor(vector_db=vector_db) + result = await executor.execute( + plan=_make_plan(), + user_filter=[], + collection_id="col-1", + db=AsyncMock(), + ctx=_make_ctx(), + collection_readable_id="my-collection", + ) + assert result.results == [fresh] + assert result.retrieval_incomplete is True + assert result.excluded_candidates == 1 + assert gate.await_args.args[3] == [fresh, stale] + + +@pytest.mark.parametrize("mode,dense_calls", [("hybrid", 1), ("keyword", 0)]) +async def test_prepared_query_reuses_only_embeddings_across_collection_scopes(mode, dense_calls): + executor = _build_executor() + executor._dense_embedder.embed_many = AsyncMock(wraps=executor._dense_embedder.embed_many) + executor._sparse_embedder.embed = AsyncMock(wraps=executor._sparse_embedder.embed) + executor._vector_db.compile_query = AsyncMock(wraps=executor._vector_db.compile_query) + executor._resolve_acl_principals = AsyncMock(side_effect=[["alice"], ["bob"]]) + plan = SearchPlan( + query=SearchQuery(primary="meeting", variations=["discussion"]), + limit=200, + offset=0, + retrieval_strategy=mode, + ) + prepared = await executor.prepare_query(plan) + ctx = MagicMock() + for collection in ("first", "second"): + filters = [ + FilterGroup( + conditions=[ + FilterCondition( + field="airweave_system_metadata.sync_id", + operator="equals", + value=collection, + ) + ] + ) + ] + result = await executor.execute( + plan, + filters, + collection, + MagicMock(), + ctx, + collection, + indexed_only=True, + prepared_query=prepared, + ) + assert result.results == [] + assert executor._dense_embedder.embed_many.await_count == dense_calls + if dense_calls: + executor._dense_embedder.embed_many.assert_awaited_once_with( + ["meeting", "discussion"], purpose="query" + ) + executor._sparse_embedder.embed.assert_awaited_once_with("meeting") + calls = executor._vector_db.compile_query.await_args_list + assert [call.kwargs["collection_id"] for call in calls] == ["first", "second"] + assert [call.kwargs["acl_principals"] for call in calls] == [["alice"], ["bob"]] + assert all(call.kwargs["plan"].limit == 200 for call in calls) + assert [call.kwargs["plan"].filter_groups[0].conditions[0].value for call in calls] == [ + "first", + "second", + ] + # A new request has no shared cache, including an identical query. + fresh = await executor.prepare_query(plan) + assert fresh is not prepared + assert executor._sparse_embedder.embed.await_count == 2 + + +async def test_prepared_query_rejects_other_executor_query_variation_and_mode(): + executor = _build_executor() + plan = SearchPlan( + query=SearchQuery(primary="meeting"), limit=200, offset=0, retrieval_strategy="keyword" + ) + prepared = await executor.prepare_query(plan) + other = _build_executor() + for candidate, candidate_plan in [ + (other, plan), + (executor, plan.model_copy(update={"query": SearchQuery(primary="other")})), + ( + executor, + plan.model_copy(update={"query": SearchQuery(primary="meeting", variations=["x"])}), + ), + (executor, plan.model_copy(update={"retrieval_strategy": "semantic"})), + ]: + with pytest.raises(ValueError, match="Prepared query"): + await candidate._execute_vector_search(candidate_plan, "collection", None, prepared) + assert executor._vector_db._calls == other._vector_db._calls == [] diff --git a/backend/airweave/domains/search/tests/test_owned_grouping.py b/backend/airweave/domains/search/tests/test_owned_grouping.py new file mode 100644 index 0000000000..69cc4e7a27 --- /dev/null +++ b/backend/airweave/domains/search/tests/test_owned_grouping.py @@ -0,0 +1,113 @@ +"""Conversation grouping retains exact, bounded independent originals.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, +) +from airweave.domains.search.owned import OwnedSearchService, _EnrichmentRecord +from airweave.domains.search.owned_models import OwnedSearchGroup, OwnedSearchHit, OwnedSearchMatch + + +def match(sync_id=None, provider="almanac", kind="message", native="m", container="s"): + return OwnedSearchHit( + record_id=uuid4(), + revision=3, + sync_id=sync_id or uuid4(), + source_connection_id=uuid4(), + provider=provider, + identity={"record_type": kind, "native_id": native, "container_id": container}, + title=native, + excerpts=("best " + native, "other"), + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + completeness="complete", + ) + + +def row(hit, **changes): + values = { + "id": hit.record_id, + "sync_id": hit.sync_id, + "record_revision": 3, + "indexed_generation": uuid4(), + "indexed_pipeline_version": 2, + "entity_definition_short_name": hit.identity.record_type, + "native_id": hit.identity.native_id, + "container_id": hit.identity.container_id, + "parent_record_type": "session", + "parent_native_id": "s", + "parent_container_id": None, + "observed_at": hit.observed_at, + "source_created_at": None, + "source_updated_at": None, + "completeness": "complete", + "email_thread_id": None, + } + values.update(changes) + return _EnrichmentRecord(**values) + + +def test_source_scoped_group_keeps_exact_anchors_and_observed_count(): + sync = uuid4() + hits = [match(sync, native=str(i)) for i in range(6)] + other = match(native="other") + for hit in hits: + hit.extraction = ExtractionCoverage( + parts=(ExtractionOutcome(part_index=0, key="body", kind="body", outcome="indexed"),) + ) + for hit in [*hits, other]: + hit.group = OwnedSearchService._conversation(row(hit), hit) + grouped = OwnedSearchService._group_hits([hits[0], other, *hits[1:]]) + assert [h.record_id for h in grouped] == [hits[0].record_id, other.record_id] + group = grouped[0].group + assert group.native_id == "s" and group.matched_records == 6 + assert len(group.additional_matches) == 3 + for original, extra in zip(hits[1:4], group.additional_matches, strict=True): + assert type(extra) is OwnedSearchMatch + assert (extra.record_id, extra.identity, extra.revision) == ( + original.record_id, + original.identity, + original.revision, + ) + assert extra.excerpts == original.excerpts[:1] + assert "group" not in extra.model_dump() + assert extra.extraction == original.extraction + assert extra.model_dump()["extraction"]["status"] == "complete" + + +def test_only_admitted_sessions_or_validated_gmail_threads_group(): + root = match(kind="session", native="s", container=None) + root.group = OwnedSearchService._conversation( + row(root, parent_record_type=None, parent_native_id=None), root + ) + child = match(root.sync_id) + child.group = OwnedSearchService._conversation(row(child), child) + assert OwnedSearchService._group_hits([child, root])[0].group.matched_records == 2 + for provider, kind in [ + ("google_calendar", "event"), + ("slack", "message"), + ("almanac", "knowledge"), + ("gdrive", "file"), + ]: + hit = match(provider=provider, kind=kind) + assert OwnedSearchService._conversation(row(hit), hit) is None + assert OwnedSearchService._conversation(row(child, parent_native_id="wrong"), child) is None + assert OwnedSearchService._conversation(row(child, parent_container_id="nested"), child) is None + gmail = match(provider="gmail") + gmail.email_thread_id = "thread_1" + assert OwnedSearchService._conversation(row(gmail), gmail).kind == "email_thread" + with pytest.raises(ValidationError): + OwnedSearchGroup(kind="record", native_id="x", matched_records=1) + with pytest.raises(ValidationError): + OwnedSearchGroup(kind="session", native_id="x", matched_records=0) + with pytest.raises(ValidationError): + OwnedSearchGroup( + kind="session", native_id="x", matched_records=5, additional_matches=[gmail] * 4 + ) diff --git a/backend/airweave/domains/search/tests/test_owned_ranking.py b/backend/airweave/domains/search/tests/test_owned_ranking.py new file mode 100644 index 0000000000..bce98eae4c --- /dev/null +++ b/backend/airweave/domains/search/tests/test_owned_ranking.py @@ -0,0 +1,196 @@ +"""Shared ranking orchestration with fake ports; SQL fencing is separately integrated.""" + +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest + +from airweave.core.protocols.reranker import RerankerResult +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.search.owned_models import OwnedSearchRequest +from airweave.domains.search.types import SearchResults + + +class CharacterTokenizer: + """Conservative fake: one character per token, including Unicode.""" + + def count_tokens(self, text): + return len(text) + + +def service(rerank=None): + return OwnedSearchService( + MagicMock(), + MagicMock(), + reranker=SimpleNamespace(rerank=rerank) if rerank else None, + tokenizer=CharacterTokenizer(), + ) + + +async def test_one_shared_call_with_exact_budget_and_no_raw_payload(): + keys = [uuid4() for _ in range(205)] + hits = { + key: SimpleNamespace(title="Title " + "x" * 300, raw_source_fields={"secret": "NEVER"}) + for key in keys + } + matched = {key: "🎵" * 3000 for key in keys} + rerank = AsyncMock(return_value=[RerankerResult(i, float(i)) for i in range(200)]) + ranked, meta = await service(rerank)._rank("query", keys, hits, matched) + assert ranked == keys[199::-1] + rerank.assert_awaited_once() + query, documents = rerank.call_args.args + assert query == "query" and len(documents) == 200 + assert all(len(doc) <= 2048 and "NEVER" not in doc for doc in documents) + assert meta.candidates_considered == 205 and meta.candidates_reranked == 200 + assert meta.shortlist_truncated and meta.input_truncated_documents == 200 + assert meta.method == "shared_rerank" and meta.fallback_reason is None + + +@pytest.mark.parametrize( + "output", + [ + [RerankerResult(0, 0.5)], + [RerankerResult(0, 0.5), RerankerResult(0, 0.3)], + [RerankerResult(True, 0.5), RerankerResult(0, 0.3)], + [RerankerResult(-1, 0.5), RerankerResult(0, 0.3)], + [RerankerResult(0, float("nan")), RerankerResult(1, 0.3)], + [RerankerResult(0, float("inf")), RerankerResult(1, 0.3)], + [{"index": 0}, {"index": 1}], + None, + ], +) +async def test_invalid_output_has_explicit_deterministic_fallback(output): + keys = [uuid4(), uuid4()] + hits = {key: SimpleNamespace(title="Title") for key in keys} + ranked, meta = await service(AsyncMock(return_value=output))._rank( + "query", + keys, + hits, + {key: "Text" for key in keys}, + ) + assert ranked == keys and meta.fallback_reason == "invalid_output" + assert meta.method == "retrieval_rank" and meta.candidates_reranked == 0 + + +@pytest.mark.parametrize( + "error,reason", [(TimeoutError(), "timeout"), (RuntimeError("secret"), "provider_error")] +) +async def test_error_fallback_is_sanitized(error, reason): + key = uuid4() + ranked, meta = await service(AsyncMock(side_effect=error))._rank( + "query", + [key], + {key: SimpleNamespace(title="Title")}, + {key: "Text"}, + ) + assert ranked == [key] and meta.fallback_reason == reason + assert "secret" not in meta.model_dump_json() + + +async def test_absent_port_and_cancellation(): + key = uuid4() + args = ("q", [key], {key: SimpleNamespace(title="Title")}, {key: "Text"}) + assert (await service()._rank(*args))[1].fallback_reason == "unconfigured" + with pytest.raises(asyncio.CancelledError): + await service(AsyncMock(side_effect=asyncio.CancelledError()))._rank(*args) + + +@pytest.mark.parametrize("withdraw", ["before", "during", None]) +async def test_global_orchestration_rechecks_before_and_after_without_holding_session(withdraw): + keys = [UUID(int=1), UUID(int=2)] + syncs = [uuid4(), uuid4()] + collections = [uuid4(), uuid4()] + hits = {key: SimpleNamespace(title="Title", group=None) for key in keys} + active = 0 + + @asynccontextmanager + async def sessions(): + nonlocal active + active += 1 + try: + yield object() + finally: + active -= 1 + + async def rerank(query, documents, top_n): + assert active == 0 + assert len(documents) == (1 if withdraw == "before" else 2) + return [RerankerResult(index, float(index)) for index in range(top_n)] + + search = service(AsyncMock(side_effect=rerank)) + search._executor.prepare_query = AsyncMock(return_value=None) + search._executor.execute = AsyncMock(return_value=SearchResults(results=[])) + search._resolve_scopes = AsyncMock( + return_value=( + {}, + {(collections[0], "one"): [syncs[0]], (collections[1], "two"): [syncs[1]]}, + ) + ) + search._enrich = AsyncMock( + side_effect=[ + ({keys[0]: hits[keys[0]]}, {keys[0]: (1, None)}, {keys[0]: "First collection"}, 0, 0), + ( + {keys[1]: hits[keys[1]]}, + {keys[1]: (0.5, None)}, + {keys[1]: "Second collection"}, + 0, + 0, + ), + ] + ) + search._final_publications = AsyncMock( + side_effect=[ + {keys[1]} if withdraw == "before" else set(keys), + {keys[1]} if withdraw else set(keys), + ] + ) + search._coverage = AsyncMock(return_value=()) + # Observe final ordering without replacing the actual ranking/session flow. + from unittest.mock import patch + + import airweave.domains.search.owned as module + + with patch.object(module, "OwnedSearchResponse", side_effect=lambda **fields: fields): + result = await search.search( + sessions, + SimpleNamespace(), + OwnedSearchRequest(query="q", sync_ids=tuple(syncs), limit=1), + ) + assert result["items"] == (hits[keys[1]],) + assert search._final_publications.await_count == 2 + assert search._resolve_scopes.await_count == 3 + assert result["excluded_candidates"] == int(withdraw is not None) + + +async def test_timeout_enforced_around_remote_await(monkeypatch): + import airweave.domains.search.owned as module + + timeout = asyncio.timeout + limits = [] + + def short_timeout(seconds): + limits.append(seconds) + return timeout(0.001) + + async def slow(*args, **kwargs): + await asyncio.sleep(1) + + monkeypatch.setattr(module.asyncio, "timeout", short_timeout) + key = uuid4() + ranked, metadata = await service(AsyncMock(side_effect=slow))._rank( + "query", + [key], + {key: SimpleNamespace(title="Title")}, + {key: "Text"}, + ) + assert limits == [10] and ranked == [key] and metadata.fallback_reason == "timeout" + + +async def test_empty_candidates_need_no_ranking_fallback(): + ranked, metadata = await service()._rank("q", [], {}, {}) + assert ranked == [] and metadata.method == "retrieval_rank" + assert metadata.fallback_reason is None and metadata.candidates_considered == 0 + assert metadata.token_count_basis == "local_tokenizer" diff --git a/backend/airweave/domains/search/tests/test_visible_vector_db.py b/backend/airweave/domains/search/tests/test_visible_vector_db.py new file mode 100644 index 0000000000..536e2e1fa1 --- /dev/null +++ b/backend/airweave/domains/search/tests/test_visible_vector_db.py @@ -0,0 +1,163 @@ +"""Every vector content retrieval must pass authoritative publication validation.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import pytest + +from airweave.domains.search.adapters.vector_db.exceptions import VectorDBError +from airweave.domains.search.agentic.state import AgentState +from airweave.domains.search.agentic.tests.conftest import make_result +from airweave.domains.search.agentic.tools.read import ReadTool +from airweave.domains.search.types.results import SearchResults +from airweave.domains.search.visible_vector_db import UnavailableExactCount, VisibleVectorDB + + +def wrapper(): + delegate = AsyncMock() + db = AsyncMock() + ctx = SimpleNamespace(organization=SimpleNamespace(id=uuid4())) + registry = Mock() + return ( + VisibleVectorDB(delegate, db, ctx, "readable", "collection", registry), + delegate, + db, + registry, + ) + + +@pytest.mark.asyncio +async def test_execute_rejects_stale_candidate_and_reports_underfilled_page(monkeypatch): + visible, delegate, _, _ = wrapper() + fresh, stale = make_result("fresh"), make_result("stale", content="do not expose") + delegate.execute_query.return_value = SearchResults(results=[fresh, stale]) + gate = AsyncMock(return_value=[fresh]) + monkeypatch.setattr("airweave.domains.search.visible_vector_db.visible_results", gate) + result = await visible.execute_query("compiled") + assert result.results == [fresh] + assert result.retrieval_incomplete is True + assert result.excluded_candidates == 1 + assert gate.await_args.args[3] == [fresh, stale] + + +@pytest.mark.asyncio +async def test_filter_read_validates_before_returning_original_text(monkeypatch): + visible, delegate, _, _ = wrapper() + stale = make_result("stale", content="do not expose") + delegate.filter_search.return_value = [stale] + gate = AsyncMock(return_value=[]) + monkeypatch.setattr("airweave.domains.search.visible_vector_db.visible_results", gate) + assert await visible.filter_search([], "collection") == [] + gate.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cached_read_does_not_fall_back_after_gate_removes_chunks(monkeypatch): + visible, delegate, _, _ = wrapper() + stale = make_result("stale", content="do not expose") + delegate.filter_search.return_value = [stale] + monkeypatch.setattr( + "airweave.domains.search.visible_vector_db.visible_results", AsyncMock(return_value=[]) + ) + state = AgentState() + state.results[stale.entity_id] = stale + result = await ReadTool(visible, "collection").execute({"entity_ids": [stale.entity_id]}, state) + assert result.entities == [] + assert result.not_found == ["stale"] + + +@pytest.mark.asyncio +async def test_cross_collection_reads_fail_before_delegate_request(): + visible, delegate, _, _ = wrapper() + with pytest.raises(VectorDBError, match="Collection"): + await visible.filter_search([], "other") + with pytest.raises(VectorDBError, match="Collection"): + await visible.compile_query(None, None, "other") + with pytest.raises(VectorDBError, match="Collection"): + await visible.count([], "other") + assert delegate.mock_calls == [] + + +@pytest.mark.asyncio +async def test_canonical_count_is_unavailable_instead_of_stale_index_total(): + visible, delegate, db, registry = wrapper() + db.scalars.return_value = [SimpleNamespace(short_name="gmail", sync_id=uuid4())] + registry.get.return_value = SimpleNamespace( + source_class_ref=SimpleNamespace(canonical_record_types=("message",)) + ) + with pytest.raises(UnavailableExactCount): + await visible.count([], "collection") + delegate.count.assert_not_called() + + +@pytest.mark.asyncio +async def test_gate_failure_propagates_without_returning_unvalidated_data(monkeypatch): + visible, delegate, _, _ = wrapper() + delegate.filter_search.return_value = [make_result("unvalidated")] + monkeypatch.setattr( + "airweave.domains.search.visible_vector_db.visible_results", + AsyncMock(side_effect=RuntimeError("database unavailable")), + ) + with pytest.raises(RuntimeError, match="database unavailable"): + await visible.filter_search([], "collection") + + +@pytest.mark.asyncio +async def test_wrapper_close_preserves_shared_adapter(): + visible, delegate, _, _ = wrapper() + await visible.close() + delegate.close.assert_not_called() + + +@pytest.mark.asyncio +async def test_cached_read_never_masks_vector_failure_with_old_text(): + visible, delegate, _, _ = wrapper() + stale = make_result("stale", content="must not appear after failure") + delegate.filter_search.side_effect = VectorDBError("vector unavailable") + state = AgentState() + state.results[stale.entity_id] = stale + with pytest.raises(VectorDBError, match="vector unavailable"): + await ReadTool(visible, "collection").execute({"entity_ids": ["stale"]}, state) + + +@pytest.mark.asyncio +async def test_legacy_count_ands_authorized_syncs_into_each_or_group(): + from airweave.domains.search.types.filters import ( + FilterableField, + FilterCondition, + FilterGroup, + FilterOperator, + ) + + visible, delegate, db, registry = wrapper() + sync_id = uuid4() + db.scalars.return_value = [SimpleNamespace(short_name="legacy", sync_id=sync_id)] + registry.get.return_value = SimpleNamespace(source_class_ref=SimpleNamespace()) + delegate.count.return_value = 7 + groups = [ + FilterGroup( + conditions=[ + FilterCondition( + field=FilterableField.NAME, operator=FilterOperator.EQUALS, value=name + ) + ] + ) + for name in ("A", "B") + ] + assert await visible.count(groups, "collection") == 7 + passed = delegate.count.await_args.args[0] + assert len(passed) == 2 + for original, scoped in zip(groups, passed, strict=True): + assert scoped.conditions[:-1] == original.conditions + assert scoped.conditions[-1].field == FilterableField.SYSTEM_METADATA_SYNC_ID + assert scoped.conditions[-1].operator == FilterOperator.IN + assert scoped.conditions[-1].value == [str(sync_id)] + + +@pytest.mark.asyncio +async def test_no_authorized_source_count_does_not_use_index_total(): + visible, delegate, db, _ = wrapper() + db.scalars.return_value = [] + assert await visible.count([], "collection") == 0 + delegate.count.assert_not_called() diff --git a/backend/airweave/domains/search/types/__init__.py b/backend/airweave/domains/search/types/__init__.py index d5c0d58952..06e524767f 100644 --- a/backend/airweave/domains/search/types/__init__.py +++ b/backend/airweave/domains/search/types/__init__.py @@ -4,6 +4,7 @@ from airweave.domains.search.types import SearchResult, FilterGroup, SearchPlan """ +from airweave.domains.search.retrieval_strategy import RetrievalStrategy from airweave.domains.search.types.embeddings import QueryEmbeddings from airweave.domains.search.types.filters import ( FilterableField, @@ -18,7 +19,6 @@ SourceMetadata, ) from airweave.domains.search.types.plan import ( - RetrievalStrategy, SearchPlan, SearchQuery, ) diff --git a/backend/airweave/domains/search/types/embeddings.py b/backend/airweave/domains/search/types/embeddings.py index 8cc403bdf4..48adb3cfd3 100644 --- a/backend/airweave/domains/search/types/embeddings.py +++ b/backend/airweave/domains/search/types/embeddings.py @@ -7,9 +7,10 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr from airweave.domains.embedders.types import DenseEmbedding, SparseEmbedding +from airweave.domains.search.types.plan import RetrievalStrategy, SearchPlan class QueryEmbeddings(BaseModel): @@ -21,3 +22,25 @@ class QueryEmbeddings(BaseModel): sparse_embedding: Optional[SparseEmbedding] = Field( default=None, description="Sparse embedding for the primary query only." ) + + +class PreparedQueryEmbeddings(BaseModel): + """Internal request-local work, valid only for its creating executor and query.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + primary: str + variations: tuple[str, ...] + strategy: RetrievalStrategy + embeddings: QueryEmbeddings + _owner: object | None = PrivateAttr(default=None) + + def require_match(self, plan: SearchPlan, owner: object) -> QueryEmbeddings: + """Never reuse another model's embeddings or silently change a query.""" + if ( + self._owner is not owner + or self.primary != plan.query.primary + or self.variations != tuple(plan.query.variations) + or self.strategy != plan.retrieval_strategy + ): + raise ValueError("Prepared query does not belong to this executor and search") + return self.embeddings diff --git a/backend/airweave/domains/search/types/filters.py b/backend/airweave/domains/search/types/filters.py index a1f03435ac..14d462197b 100644 --- a/backend/airweave/domains/search/types/filters.py +++ b/backend/airweave/domains/search/types/filters.py @@ -38,6 +38,11 @@ class FilterableField(str, Enum): SYSTEM_METADATA_CHUNK_INDEX = "airweave_system_metadata.chunk_index" SYSTEM_METADATA_SYNC_ID = "airweave_system_metadata.sync_id" SYSTEM_METADATA_SYNC_JOB_ID = "airweave_system_metadata.sync_job_id" + CANONICAL_RECORD_TYPE = "airweave_system_metadata.canonical_record_type" + CANONICAL_SOURCE_CREATED_US = "airweave_system_metadata.source_created_us" + CANONICAL_SOURCE_UPDATED_US = "airweave_system_metadata.source_updated_us" + CANONICAL_SOURCE_CREATED_KNOWN = "airweave_system_metadata.source_created_known" + CANONICAL_SOURCE_UPDATED_KNOWN = "airweave_system_metadata.source_updated_known" class FilterOperator(str, Enum): @@ -66,6 +71,7 @@ class FilterOperator(str, Enum): FilterableField.BREADCRUMBS_ENTITY_TYPE, FilterableField.SYSTEM_METADATA_ENTITY_TYPE, FilterableField.SYSTEM_METADATA_SOURCE_NAME, + FilterableField.CANONICAL_RECORD_TYPE, FilterableField.SYSTEM_METADATA_ORIGINAL_ENTITY_ID, FilterableField.SYSTEM_METADATA_SYNC_ID, FilterableField.SYSTEM_METADATA_SYNC_JOB_ID, @@ -82,6 +88,10 @@ class FilterOperator(str, Enum): _NUMERIC_FIELDS: frozenset[FilterableField] = frozenset( { FilterableField.SYSTEM_METADATA_CHUNK_INDEX, + FilterableField.CANONICAL_SOURCE_CREATED_US, + FilterableField.CANONICAL_SOURCE_UPDATED_US, + FilterableField.CANONICAL_SOURCE_CREATED_KNOWN, + FilterableField.CANONICAL_SOURCE_UPDATED_KNOWN, } ) diff --git a/backend/airweave/domains/search/types/metadata.py b/backend/airweave/domains/search/types/metadata.py index a574e007c5..5793543cea 100644 --- a/backend/airweave/domains/search/types/metadata.py +++ b/backend/airweave/domains/search/types/metadata.py @@ -12,7 +12,9 @@ class EntityTypeMetadata(BaseModel): """Entity type metadata with fields.""" name: str = Field(..., description="Entity type name (e.g., 'NotionPageEntity').") - count: int = Field(..., description="Number of entities of this type.") + count: int | None = Field( + ..., description="Known projection entity count, or null when not measured." + ) fields: dict[str, str] = Field(..., description="Field names mapped to their descriptions.") def to_md(self) -> str: @@ -23,7 +25,8 @@ def to_md(self) -> str: self-explanatory enough for the LLM to craft queries and filters. """ field_names = ", ".join(sorted(self.fields.keys())) - return f"- **{self.name}** ({self.count}) — fields: {field_names}" + count = self.count if self.count is not None else "indexed count unknown" + return f"- **{self.name}** ({count}) — fields: {field_names}" class SourceMetadata(BaseModel): @@ -34,6 +37,10 @@ class SourceMetadata(BaseModel): entity_types: list[EntityTypeMetadata] = Field( ..., description="Entity types with their fields and counts." ) + captured_record_counts: dict[str, int] | None = Field( + default=None, + description="Visible captured records by native record type; not index counts.", + ) federated: bool = Field( default=False, description="Whether this source uses federated (real-time) search instead of syncing.", @@ -53,12 +60,30 @@ def to_md(self) -> str: f"Filters work normally. No entity counts available.*" ) - # Only show entity types that actually have entities - non_empty = [et for et in self.entity_types if et.count > 0] + if self.captured_record_counts is not None: + counts = ", ".join( + f"{kind}: {count}" for kind, count in sorted(self.captured_record_counts.items()) + ) + lines = [ + f"### {self.short_name}", + self.description, + f"Captured records ({sum(self.captured_record_counts.values())} total): {counts}.", + "These are captured records; search indexing may lag or be incomplete.", + "Searchable projection schemas (counts are not measured):", + *(entity.to_md() for entity in self.entity_types), + ] + return "\n".join(lines) + + # Only show known nonempty or unmeasured projection entity types. + non_empty = [et for et in self.entity_types if et.count is None or et.count > 0] if not non_empty: return f"### {self.short_name}\n{self.description}\n\n*(no entities)*" - total = sum(et.count for et in non_empty) + total = ( + sum(et.count for et in non_empty) + if all(et.count is not None for et in non_empty) + else "unknown" + ) lines = [ f"### {self.short_name} ({total} entities total)", f"{self.description}", diff --git a/backend/airweave/domains/search/types/plan.py b/backend/airweave/domains/search/types/plan.py index c9af96f9bc..64077b31ba 100644 --- a/backend/airweave/domains/search/types/plan.py +++ b/backend/airweave/domains/search/types/plan.py @@ -5,22 +5,14 @@ from __future__ import annotations -from enum import Enum from typing import List from pydantic import BaseModel, Field +from airweave.domains.search.retrieval_strategy import RetrievalStrategy from airweave.domains.search.types.filters import FilterGroup -class RetrievalStrategy(str, Enum): - """Supported retrieval strategies.""" - - SEMANTIC = "semantic" - KEYWORD = "keyword" - HYBRID = "hybrid" - - class SearchQuery(BaseModel): """Search query.""" diff --git a/backend/airweave/domains/search/types/results.py b/backend/airweave/domains/search/types/results.py index 367b94efd4..c1fe9459f2 100644 --- a/backend/airweave/domains/search/types/results.py +++ b/backend/airweave/domains/search/types/results.py @@ -100,13 +100,16 @@ class SearchResult(BaseModel): ) textual_representation: str = Field(..., description="Semantically searchable text content") + query_snippet: str | None = Field( + default=None, exclude=True, description="Optional Vespa lexical fragment; display only." + ) airweave_system_metadata: SearchSystemMetadata = Field(..., description="System metadata") access: SearchAccessControl = Field(..., description="Access control") web_url: str = Field( ..., - description="URL to view the entity in its source application (e.g., Notion, Asana).", + description="Source application URL, or empty when the record uses a canonical locator.", ) url: Optional[str] = Field( @@ -239,6 +242,19 @@ class SearchResults(BaseModel): description="Search results ordered by relevance (highest first).", ) + engine_partial: bool = False + engine_coverage_percent: float | None = None + + retrieval_incomplete: bool = Field( + default=False, + description="Some retrieved candidates were unavailable; this page may underfill.", + ) + excluded_candidates: int = Field( + default=0, + ge=0, + description="Stale or unauthorized index candidates omitted from this page.", + ) + def __len__(self) -> int: """Return the number of results.""" return len(self.results) diff --git a/backend/airweave/domains/search/visible_vector_db.py b/backend/airweave/domains/search/visible_vector_db.py new file mode 100644 index 0000000000..cde8b80358 --- /dev/null +++ b/backend/airweave/domains/search/visible_vector_db.py @@ -0,0 +1,138 @@ +"""Request-scoped vector reads validated against the authoritative publication store.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.api.context import ApiContext +from airweave.domains.search.adapters.vector_db.exceptions import VectorDBError +from airweave.domains.search.adapters.vector_db.protocol import VectorDBProtocol +from airweave.domains.search.canonical_visibility import visible_results +from airweave.domains.search.types import QueryEmbeddings, SearchPlan, SearchResults +from airweave.domains.search.types.filters import ( + FilterableField, + FilterCondition, + FilterGroup, + FilterOperator, +) +from airweave.domains.search.types.results import CompiledQuery, SearchResult +from airweave.domains.sources.protocols import SourceRegistryProtocol +from airweave.models.source_connection import SourceConnection + + +class UnavailableExactCount(VectorDBError): + """The index cannot prove an exact currently visible record count.""" + + +class VisibleVectorDB: + """Same adapter contract with current publication checks on every content retrieval.""" + + def __init__( + self, + delegate: VectorDBProtocol, + db: AsyncSession, + ctx: ApiContext, + readable_id: str, + collection_id: str, + registry: SourceRegistryProtocol, + ) -> None: + """Bind one authenticated collection; callers cannot widen that scope.""" + self._delegate = delegate + self._db = db + self._ctx = ctx + self._readable_id = readable_id + self._collection_id = collection_id + self._registry = registry + + def _check_scope(self, collection_id: str) -> None: + if collection_id != self._collection_id: + raise VectorDBError("Collection does not match this search request") + + async def validate(self, results: list[SearchResult]) -> list[SearchResult]: + """Revalidate even previously cached results before further consumption.""" + return await visible_results( + self._db, self._ctx.organization.id, self._readable_id, results, self._registry + ) + + async def compile_query( + self, + plan: SearchPlan, + embeddings: QueryEmbeddings, + collection_id: str, + acl_principals: list[str] | None = None, + ) -> CompiledQuery: + """Compile only within the bound collection.""" + self._check_scope(collection_id) + return await self._delegate.compile_query(plan, embeddings, collection_id, acl_principals) + + async def execute_query(self, compiled_query: CompiledQuery) -> SearchResults: + """Validate returned content regardless of query fields.""" + result = await self._delegate.execute_query(compiled_query) + visible = await self.validate(result.results) + excluded = len(result.results) - len(visible) + return result.model_copy( + update={ + "results": visible, + "retrieval_incomplete": result.retrieval_incomplete or excluded > 0, + "excluded_candidates": result.excluded_candidates + excluded, + } + ) + + async def filter_search( + self, + filter_groups: list[FilterGroup], + collection_id: str, + limit: int = 50, + offset: int = 0, + name_substring: str | None = None, + ) -> list[SearchResult]: + """Validate read and navigation candidates before returning any text.""" + self._check_scope(collection_id) + results = await self._delegate.filter_search( + filter_groups, collection_id, limit, offset, name_substring + ) + return await self.validate(results) + + async def count( + self, + filter_groups: list[FilterGroup], + collection_id: str, + name_substring: str | None = None, + ) -> int: + """Legacy index counts remain available; canonical counts need database semantics.""" + self._check_scope(collection_id) + sources = list( + await self._db.scalars( + select(SourceConnection).where( + SourceConnection.organization_id == self._ctx.organization.id, + SourceConnection.readable_collection_id == self._readable_id, + SourceConnection.is_authenticated.is_(True), + SourceConnection.sync_id.is_not(None), + ) + ) + ) + for source in sources: + if getattr( + self._registry.get(source.short_name).source_class_ref, + "canonical_record_types", + (), + ): + raise UnavailableExactCount( + "Exact current count is unavailable for this indexed collection; " + "enumerate committed source records instead." + ) + if not sources: + return 0 + allowed = FilterCondition( + field=FilterableField.SYSTEM_METADATA_SYNC_ID, + operator=FilterOperator.IN, + value=[str(source.sync_id) for source in sources], + ) + scoped = ( + [FilterGroup(conditions=[*group.conditions, allowed]) for group in filter_groups] + if filter_groups + else [FilterGroup(conditions=[allowed])] + ) + return await self._delegate.count(scoped, collection_id, name_substring) + + async def close(self) -> None: + """The shared adapter is owned by the application, not this request.""" diff --git a/backend/airweave/domains/source_connections/create.py b/backend/airweave/domains/source_connections/create.py index 63bc07acae..cacfbc9c90 100644 --- a/backend/airweave/domains/source_connections/create.py +++ b/backend/airweave/domains/source_connections/create.py @@ -2,6 +2,7 @@ import hashlib import secrets +from contextlib import nullcontext from typing import Any, Optional from uuid import UUID @@ -22,6 +23,7 @@ from airweave.domains.connections.protocols import ConnectionRepositoryProtocol from airweave.domains.credentials.protocols import IntegrationCredentialServiceProtocol from airweave.domains.oauth.protocols import OAuthFlowServiceProtocol +from airweave.domains.owned_provisioning.guard import require_provider_source from airweave.domains.source_connections.protocols import ( ResponseBuilderProtocol, SourceConnectionCreateServiceProtocol, @@ -163,6 +165,25 @@ async def create( ) return result + async def create_deferred( + self, + db: AsyncSession, + *, + obj_in: SourceConnectionCreate, + ctx: ApiContext, + uow: UnitOfWork, + ) -> SourceConnectionSchema: + """Persist managed source in caller transaction without Temporal side effects.""" + if obj_in.sync_immediately: + raise ValueError("Deferred creation cannot start a job before native verification") + entry = self._get_source_entry(obj_in.short_name) + self._validate_auth_compatibility( + entry.source_class_ref, entry.short_name, AuthenticationMethod.AUTH_PROVIDER + ) + return await self._create_with_auth_provider( + db, obj_in=obj_in, entry=entry, ctx=ctx, transaction=uow + ) + async def reinitiate_oauth( self, db: AsyncSession, @@ -178,6 +199,7 @@ async def reinitiate_oauth( source_conn = await self._sc_repo.get(db, id=id, ctx=ctx) if not source_conn: raise NotFoundException("Source connection not found") + require_provider_source(source_conn.short_name) if source_conn.is_authenticated and not await self._has_credential_error(db, source_conn): raise HTTPException( status_code=400, @@ -388,7 +410,13 @@ async def _create_with_oauth_token( ) async def _create_with_auth_provider( - self, db: AsyncSession, *, obj_in: SourceConnectionCreate, entry, ctx: ApiContext + self, + db: AsyncSession, + *, + obj_in: SourceConnectionCreate, + entry, + ctx: ApiContext, + transaction: UnitOfWork | None = None, ) -> SourceConnectionSchema: if not obj_in.authentication or not isinstance( obj_in.authentication, AuthProviderAuthentication @@ -430,7 +458,7 @@ async def _create_with_auth_provider( connection_schema: Optional[schemas.Connection] = None collection_schema: Optional[schemas.CollectionRecord] = None - async with UnitOfWork(db) as uow: + async with nullcontext(transaction) if transaction else UnitOfWork(db) as uow: collection = await self._get_collection(uow.session, obj_in.readable_collection_id, ctx) collection_schema = schemas.CollectionRecord.model_validate( collection, from_attributes=True @@ -460,10 +488,12 @@ async def _create_with_auth_provider( collection_id=collection.id, collection_readable_id=collection.readable_id, source_entry=entry, + source_config=validated_config, schedule_config=obj_in.schedule, run_immediately=bool(obj_in.sync_immediately), ctx=ctx, uow=uow, + **({"defer_execution": True} if transaction else {}), ) await uow.session.flush() @@ -485,11 +515,12 @@ async def _create_with_auth_provider( uow=uow, ) await uow.session.flush() - await uow.commit() + if transaction is None: + await uow.commit() await uow.session.refresh(source_conn) response = await self._response_builder.build_response(db, source_conn, ctx) - if sync_result and sync_result.sync_job and obj_in.sync_immediately: + if transaction is None and sync_result and sync_result.sync_job and obj_in.sync_immediately: if connection_schema is None or collection_schema is None: raise RuntimeError("Connection or collection schema not materialized") await self._trigger_sync_workflow( @@ -672,6 +703,7 @@ async def _create_authenticated_connection( collection_id=collection.id, collection_readable_id=collection.readable_id, source_entry=entry, + source_config=validated_config, schedule_config=obj_in.schedule, run_immediately=bool(obj_in.sync_immediately), ctx=ctx, diff --git a/backend/airweave/domains/source_connections/delete.py b/backend/airweave/domains/source_connections/delete.py index 8a25a14169..5e36e61501 100644 --- a/backend/airweave/domains/source_connections/delete.py +++ b/backend/airweave/domains/source_connections/delete.py @@ -8,6 +8,7 @@ from airweave.api.context import ApiContext from airweave.core.exceptions import NotFoundException from airweave.domains.collections.protocols import CollectionRepositoryProtocol +from airweave.domains.owned_provisioning.guard import require_unmanaged_source from airweave.domains.source_connections.protocols import ( ResponseBuilderProtocol, SourceConnectionDeletionServiceProtocol, @@ -49,6 +50,10 @@ async def delete( if not source_conn: raise NotFoundException("Source connection not found") + await require_unmanaged_source( + db, id, ctx.organization.id, short_name=source_conn.short_name + ) + sync_id = source_conn.sync_id collection_orm = await self._collection_repo.get_by_readable_id( db, readable_id=source_conn.readable_collection_id, ctx=ctx diff --git a/backend/airweave/domains/source_connections/protocols.py b/backend/airweave/domains/source_connections/protocols.py index ae385cc697..a69071b7f9 100644 --- a/backend/airweave/domains/source_connections/protocols.py +++ b/backend/airweave/domains/source_connections/protocols.py @@ -188,6 +188,17 @@ async def create( """Create a source connection.""" ... + async def create_deferred( + self, + db: AsyncSession, + *, + obj_in: SourceConnectionCreate, + ctx: ApiContext, + uow: UnitOfWork, + ) -> SourceConnectionSchema: + """Create a managed source in the caller transaction without execution.""" + ... + async def reinitiate_oauth( self, db: AsyncSession, diff --git a/backend/airweave/domains/source_connections/service.py b/backend/airweave/domains/source_connections/service.py index 33cc05ef6f..987da12fef 100644 --- a/backend/airweave/domains/source_connections/service.py +++ b/backend/airweave/domains/source_connections/service.py @@ -16,6 +16,7 @@ from airweave.domains.collections.protocols import CollectionRepositoryProtocol from airweave.domains.connections.protocols import ConnectionRepositoryProtocol from airweave.domains.oauth.protocols import OAuthRedirectSessionRepositoryProtocol +from airweave.domains.owned_provisioning.guard import require_provider_source from airweave.domains.source_connections.protocols import ( ResponseBuilderProtocol, SourceConnectionCreateServiceProtocol, @@ -170,6 +171,7 @@ async def run( ) -> SourceConnectionJob: """Trigger a sync run for this source connection.""" source_conn = await self._resolve_source_connection(db, id, ctx) + require_provider_source(source_conn.short_name) sync_id = source_conn.sync_id assert sync_id is not None diff --git a/backend/airweave/domains/source_connections/tests/test_create.py b/backend/airweave/domains/source_connections/tests/test_create.py index b57f596f27..227b027c40 100644 --- a/backend/airweave/domains/source_connections/tests/test_create.py +++ b/backend/airweave/domains/source_connections/tests/test_create.py @@ -1117,3 +1117,13 @@ async def test_reinitiate_oauth_preserves_byoc_credentials(monkeypatch): assert init_kwargs["template_configs"] == {"subdomain": "acme"} # Verify payload was reused from old init session assert init_kwargs["payload"]["name"] == "My GitHub" + + +async def test_native_source_cannot_start_provider_oauth(): + svc = _service(_entry(oauth_type="access_only")) + sc = _shell_source_conn() + sc.short_name = "almanac" + svc._sc_repo.seed(sc.id, sc) + with pytest.raises(HTTPException) as error: + await svc.reinitiate_oauth(AsyncMock(), id=sc.id, ctx=_ctx()) + assert error.value.status_code == 409 diff --git a/backend/airweave/domains/source_connections/tests/test_delete.py b/backend/airweave/domains/source_connections/tests/test_delete.py index a70bf44288..fc632ca091 100644 --- a/backend/airweave/domains/source_connections/tests/test_delete.py +++ b/backend/airweave/domains/source_connections/tests/test_delete.py @@ -11,6 +11,7 @@ from uuid import uuid4 import pytest +from fastapi import HTTPException from airweave.api.context import ApiContext from airweave.core.exceptions import NotFoundException @@ -32,6 +33,11 @@ COLLECTION_ID = uuid4() +def _unmanaged_db(): + """Legacy scenarios have no owned account relationship.""" + return AsyncMock(scalar=AsyncMock(return_value=None)) + + def _make_ctx() -> ApiContext: org = Organization(id=str(ORG_ID), name="Test Org", created_at=NOW, modified_at=NOW) return ApiContext( @@ -42,7 +48,9 @@ def _make_ctx() -> ApiContext: ) -def _make_sc(*, id=None, sync_id=None, readable_collection_id="test-col", name="Test SC", short_name="github"): +def _make_sc( + *, id=None, sync_id=None, readable_collection_id="test-col", name="Test SC", short_name="github" +): sc = MagicMock(spec=SourceConnection) sc.id = id or uuid4() sc.sync_id = sync_id @@ -123,7 +131,7 @@ async def test_delete_happy_path(case: DeleteCase): sync_service=sync_service, ) - result = await svc.delete(AsyncMock(), id=sc.id, ctx=_make_ctx()) + result = await svc.delete(_unmanaged_db(), id=sc.id, ctx=_make_ctx()) assert result.id == sc.id assert sc_repo._store.get(sc.id) is None @@ -149,8 +157,20 @@ class DeleteErrorCase: DELETE_ERROR_CASES = [ - DeleteErrorCase("not_found", seed_sc=False, seed_collection=False, expect_exception=NotFoundException, expect_match="Source connection not found"), - DeleteErrorCase("collection_not_found", seed_sc=True, seed_collection=False, expect_exception=NotFoundException, expect_match="Collection not found"), + DeleteErrorCase( + "not_found", + seed_sc=False, + seed_collection=False, + expect_exception=NotFoundException, + expect_match="Source connection not found", + ), + DeleteErrorCase( + "collection_not_found", + seed_sc=True, + seed_collection=False, + expect_exception=NotFoundException, + expect_match="Collection not found", + ), ] @@ -168,7 +188,7 @@ async def test_delete_error(case: DeleteErrorCase): svc = _build_service(sc_repo=sc_repo, collection_repo=col_repo) with pytest.raises(case.expect_exception, match=case.expect_match): - await svc.delete(AsyncMock(), id=sc.id, ctx=_make_ctx()) + await svc.delete(_unmanaged_db(), id=sc.id, ctx=_make_ctx()) async def test_delete_sync_service_failure_propagates(): @@ -192,4 +212,17 @@ async def test_delete_sync_service_failure_propagates(): ) with pytest.raises(RuntimeError, match="sync delete boom"): - await svc.delete(AsyncMock(), id=sc.id, ctx=_make_ctx()) + await svc.delete(_unmanaged_db(), id=sc.id, ctx=_make_ctx()) + + +async def test_native_delete_rejected_before_cleanup(): + sc = _make_sc(short_name="almanac") + repo = FakeSourceConnectionRepository() + repo.seed(sc.id, sc) + sync_service = AsyncMock() + with pytest.raises(HTTPException) as error: + await _build_service(sc_repo=repo, sync_service=sync_service).delete( + _unmanaged_db(), id=sc.id, ctx=_make_ctx() + ) + assert error.value.status_code == 409 + sync_service.delete.assert_not_awaited() diff --git a/backend/airweave/domains/source_connections/tests/test_service.py b/backend/airweave/domains/source_connections/tests/test_service.py index def0b11696..aaaa52aca3 100644 --- a/backend/airweave/domains/source_connections/tests/test_service.py +++ b/backend/airweave/domains/source_connections/tests/test_service.py @@ -13,6 +13,7 @@ from uuid import UUID, uuid4 import pytest +from fastapi import HTTPException from airweave.api.context import ApiContext from airweave.core.datetime_utils import utc_now @@ -689,3 +690,16 @@ async def test_count_by_organization(): svc = _build_run_service(sc_repo=sc_repo) count = await svc.count_by_organization(AsyncMock(), organization_id=ORG_ID) assert count == 0 + + +async def test_native_source_cannot_start_provider_pull_run(): + sc = _make_source_conn() + sc.short_name = "almanac" + repo = FakeSourceConnectionRepository() + repo.seed(SC_ID, sc) + sync_service = _RecordingFakeSyncService() + svc = _build_run_service(sc_repo=repo, sync_service=sync_service) + with pytest.raises(HTTPException) as error: + await svc.run(AsyncMock(), id=SC_ID, ctx=_make_ctx(), force_full_sync=True) + assert error.value.status_code == 409 + assert sync_service.last_trigger_run is None diff --git a/backend/airweave/domains/source_connections/tests/test_update.py b/backend/airweave/domains/source_connections/tests/test_update.py index 581cec880c..990840aa97 100644 --- a/backend/airweave/domains/source_connections/tests/test_update.py +++ b/backend/airweave/domains/source_connections/tests/test_update.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Optional +from typing import Optional from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -19,7 +19,6 @@ from airweave.core.exceptions import NotFoundException from airweave.core.logging import logger from airweave.core.shared_models import AuthMethod -from airweave.domains.syncs.types import InvalidSyncTransitionError from airweave.domains.collections.fakes.repository import FakeCollectionRepository from airweave.domains.connections.fakes.repository import FakeConnectionRepository from airweave.domains.credentials.fakes.repository import ( @@ -34,8 +33,8 @@ from airweave.domains.sources.fakes.service import FakeSourceService from airweave.domains.sources.fakes.validation import FakeSourceValidationService from airweave.domains.sources.types import SourceRegistryEntry -from airweave.domains.syncs.fakes.service import FakeSyncService from airweave.domains.syncs.fakes.repository import FakeSyncRepository +from airweave.domains.syncs.fakes.service import FakeSyncService from airweave.domains.syncs.types import SyncProvisionResult from airweave.domains.temporal.fakes.schedule_service import FakeTemporalScheduleService from airweave.models.collection import Collection @@ -54,6 +53,11 @@ class _AuthPayload(BaseModel): token: str +def _unmanaged_db(): + """Legacy scenarios have no owned account relationship.""" + return AsyncMock(scalar=AsyncMock(return_value=None)) + + def _make_ctx() -> ApiContext: org = Organization(id=str(ORG_ID), name="Test Org", created_at=NOW, modified_at=NOW) return ApiContext( @@ -141,7 +145,7 @@ async def test_update_not_found(): svc = _build_service() obj_in = SourceConnectionUpdate(name="New Name") with pytest.raises(NotFoundException, match="Source connection not found"): - await svc.update(AsyncMock(), id=uuid4(), obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=uuid4(), obj_in=obj_in, ctx=_make_ctx()) # --------------------------------------------------------------------------- @@ -159,7 +163,9 @@ class FieldUpdateCase: FIELD_UPDATE_CASES = [ FieldUpdateCase("name_only", SourceConnectionUpdate(name="Renamed")), FieldUpdateCase("description_only", SourceConnectionUpdate(description="New desc")), - FieldUpdateCase("name_and_description", SourceConnectionUpdate(name="Renamed", description="Desc")), + FieldUpdateCase( + "name_and_description", SourceConnectionUpdate(name="Renamed", description="Desc") + ), ] @@ -170,7 +176,7 @@ async def test_field_update(case: FieldUpdateCase): sc_repo.seed(sc.id, sc) svc = _build_service(sc_repo=sc_repo) - result = await svc.update(AsyncMock(), id=sc.id, obj_in=case.obj_in, ctx=_make_ctx()) + result = await svc.update(_unmanaged_db(), id=sc.id, obj_in=case.obj_in, ctx=_make_ctx()) assert result.id == sc.id @@ -190,11 +196,84 @@ async def test_update_config_valid(): svc = _build_service(sc_repo=sc_repo, source_validation=validation) obj_in = SourceConnectionUpdate(config={"key": "value"}) - result = await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + result = await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert result.id == sc.id assert any(c[0] == "validate_config" for c in validation._calls) +@pytest.mark.parametrize("provider", ["outlook_mail", "outlook_calendar"]) +@pytest.mark.parametrize("originals", [False, True]) +async def test_outlook_capture_mode_change_requires_new_source(originals: bool, provider: str): + sc = _make_sc(short_name=provider) + sc.config_fields = {"expected_principal_id": "native-user", "capture_originals": originals} + sc_repo = FakeSourceConnectionRepository() + sc_repo.seed(sc.id, sc) + config = {"expected_principal_id": "native-user", "capture_originals": not originals} + validation = FakeSourceValidationService() + validation.seed_config_result(provider, config) + svc = _build_service(sc_repo=sc_repo, source_validation=validation) + + with pytest.raises(HTTPException, match="Create a new source") as exc: + await svc.update( + _unmanaged_db(), id=sc.id, obj_in=SourceConnectionUpdate(config=config), ctx=_make_ctx() + ) + + assert exc.value.status_code == 400 + assert sc.config_fields["capture_originals"] is originals + assert not any(call[0] == "update" for call in sc_repo._calls) + + +@pytest.mark.parametrize("provider", ["outlook_mail", "outlook_calendar"]) +@pytest.mark.parametrize("originals", [False, True]) +async def test_outlook_principal_change_preserves_capture_identity(originals: bool, provider: str): + sc = _make_sc(short_name=provider) + sc.config_fields = {"expected_principal_id": "native-user", "capture_originals": originals} + sc_repo = FakeSourceConnectionRepository() + sc_repo.seed(sc.id, sc) + config = {**sc.config_fields, "expected_principal_id": "another-user"} + validation = FakeSourceValidationService() + validation.seed_config_result(provider, config) + svc = _build_service(sc_repo=sc_repo, source_validation=validation) + + if originals: + with pytest.raises(HTTPException, match="Create a new source") as exc: + await svc.update( + _unmanaged_db(), + id=sc.id, + obj_in=SourceConnectionUpdate(config=config), + ctx=_make_ctx(), + ) + assert exc.value.status_code == 400 + assert sc.config_fields["expected_principal_id"] == "native-user" + assert not any(call[0] == "update" for call in sc_repo._calls) + else: + await svc.update( + _unmanaged_db(), id=sc.id, obj_in=SourceConnectionUpdate(config=config), ctx=_make_ctx() + ) + assert sc.config_fields["expected_principal_id"] == "another-user" + + +@pytest.mark.parametrize("originals", [None, False, True]) +async def test_outlook_unchanged_capture_mode_allows_config_update(originals: bool | None): + sc = _make_sc(short_name="outlook_mail") + sc.config_fields = {"expected_principal_id": "native-user"} + if originals is not None: + sc.config_fields["capture_originals"] = originals + sc_repo = FakeSourceConnectionRepository() + sc_repo.seed(sc.id, sc) + config = {**sc.config_fields, "included_folders": ["inbox"]} + validation = FakeSourceValidationService() + validation.seed_config_result("outlook_mail", config) + svc = _build_service(sc_repo=sc_repo, source_validation=validation) + + result = await svc.update( + _unmanaged_db(), id=sc.id, obj_in=SourceConnectionUpdate(config=config), ctx=_make_ctx() + ) + + assert result.id == sc.id + assert sc.config_fields == config + + # --------------------------------------------------------------------------- # Schedule updates -- table-driven # --------------------------------------------------------------------------- @@ -212,10 +291,36 @@ class ScheduleCase: SCHEDULE_CASES = [ - ScheduleCase("update_existing", has_sync=True, new_cron="0 * * * *", expect_temporal_create=True, expect_temporal_delete=False), - ScheduleCase("remove_schedule", has_sync=True, new_cron=None, expect_temporal_create=False, expect_temporal_delete=True), - ScheduleCase("add_no_sync", has_sync=False, new_cron="0 * * * *", expect_temporal_create=False, expect_temporal_delete=False, expect_sync_record_create=True), - ScheduleCase("no_connection_id_warning", has_sync=False, new_cron="0 * * * *", has_connection_id=False, expect_temporal_create=False, expect_temporal_delete=False), + ScheduleCase( + "update_existing", + has_sync=True, + new_cron="0 * * * *", + expect_temporal_create=True, + expect_temporal_delete=False, + ), + ScheduleCase( + "remove_schedule", + has_sync=True, + new_cron=None, + expect_temporal_create=False, + expect_temporal_delete=True, + ), + ScheduleCase( + "add_no_sync", + has_sync=False, + new_cron="0 * * * *", + expect_temporal_create=False, + expect_temporal_delete=False, + expect_sync_record_create=True, + ), + ScheduleCase( + "no_connection_id_warning", + has_sync=False, + new_cron="0 * * * *", + has_connection_id=False, + expect_temporal_create=False, + expect_temporal_delete=False, + ), ] @@ -288,7 +393,7 @@ async def test_schedule_update(case: ScheduleCase): collection_repo=col_repo, ) - result = await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + result = await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert result.id == sc.id if case.expect_temporal_create: @@ -311,7 +416,7 @@ async def test_schedule_add_collection_not_found(): obj_in = SourceConnectionUpdate(schedule={"cron": "0 * * * *"}) with pytest.raises(NotFoundException, match="Collection not found"): - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) async def test_schedule_add_rejects_federated_source(): @@ -348,7 +453,7 @@ async def test_schedule_add_rejects_federated_source(): obj_in = SourceConnectionUpdate(schedule={"cron": "0 * * * *"}) with pytest.raises(HTTPException) as exc_info: - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert exc_info.value.status_code == 400 assert "federated search" in str(exc_info.value.detail) @@ -415,13 +520,13 @@ async def test_credential_update(case: CredentialCase): obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) if case.expect_success: - result = await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + result = await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert result.id == sc.id assert len(encryptor._encrypt_calls) == 1 assert any(c[0] == "update" for c in cred_repo._calls) else: with pytest.raises(HTTPException) as exc_info: - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert exc_info.value.status_code == 400 assert "direct authentication" in str(exc_info.value.detail) @@ -441,7 +546,7 @@ async def test_credential_update_direct_auth_missing_connection_raises(): obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) with pytest.raises(NotFoundException, match="Connection not found"): - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) async def test_credential_update_direct_auth_missing_integration_credential_raises(): @@ -469,7 +574,7 @@ async def test_credential_update_direct_auth_missing_integration_credential_rais obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) with pytest.raises(NotFoundException, match="Integration credential not configured"): - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) async def test_credential_update_direct_auth_missing_credential_record_raises(): @@ -498,7 +603,7 @@ async def test_credential_update_direct_auth_missing_credential_record_raises(): obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) with pytest.raises(NotFoundException, match="Integration credential not found"): - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) # --------------------------------------------------------------------------- @@ -579,7 +684,7 @@ async def test_credential_update_triggers_unpause(): ) obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) - await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert any(c[0] == "resume" for c in sync_svc._calls) @@ -622,5 +727,53 @@ async def test_credential_update_unpause_failure_is_nonfatal(): ) obj_in = SourceConnectionUpdate(authentication={"credentials": {"token": "new_secret"}}) - result = await svc.update(AsyncMock(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) + result = await svc.update(_unmanaged_db(), id=sc.id, obj_in=obj_in, ctx=_make_ctx()) assert result.id == sc.id + + +async def test_native_update_cannot_rebind_identity(): + sc = _make_sc(short_name="almanac") + repo = FakeSourceConnectionRepository() + repo.seed(sc.id, sc) + db = _unmanaged_db() + with pytest.raises(HTTPException) as error: + await _build_service(sc_repo=repo).update( + db, + id=sc.id, + obj_in=SourceConnectionUpdate(config={"owner_id": "other"}), + ctx=_make_ctx(), + ) + assert error.value.status_code == 409 + db.commit.assert_not_awaited() + db.scalar.assert_not_awaited() + + +@pytest.mark.parametrize( + "change", + [ + None, + {"livemode": True}, + {"expected_account_id": "acct_other"}, + {"api_version": "2026-01-28.clover"}, + ], +) +async def test_stripe_capture_binding_cannot_change_in_place(change): + binding = { + "expected_account_id": "acct_selected", + "livemode": False, + "api_version": "2025-06-30.basil", + } + sc = _make_sc(short_name="stripe") + sc.config_fields = {"original_capture": binding} + sc_repo = FakeSourceConnectionRepository() + sc_repo.seed(sc.id, sc) + config = {"original_capture": None if change is None else {**binding, **change}} + validation = FakeSourceValidationService() + validation.seed_config_result("stripe", config) + svc = _build_service(sc_repo=sc_repo, source_validation=validation) + with pytest.raises(HTTPException, match="Create a new source"): + await svc.update( + _unmanaged_db(), id=sc.id, obj_in=SourceConnectionUpdate(config=config), ctx=_make_ctx() + ) + assert sc.config_fields == {"original_capture": binding} + assert not any(call[0] == "update" for call in sc_repo._calls) diff --git a/backend/airweave/domains/source_connections/update.py b/backend/airweave/domains/source_connections/update.py index 670220f2ab..f1b199037a 100644 --- a/backend/airweave/domains/source_connections/update.py +++ b/backend/airweave/domains/source_connections/update.py @@ -15,6 +15,7 @@ from airweave.domains.collections.protocols import CollectionRepositoryProtocol from airweave.domains.connections.protocols import ConnectionRepositoryProtocol from airweave.domains.credentials.protocols import IntegrationCredentialRepositoryProtocol +from airweave.domains.owned_provisioning.guard import require_unmanaged_source from airweave.domains.source_connections.protocols import ( ResponseBuilderProtocol, SourceConnectionRepositoryProtocol, @@ -30,6 +31,7 @@ from airweave.domains.syncs.types import InvalidSyncTransitionError, OptimisticLockError from airweave.domains.temporal.protocols import TemporalScheduleServiceProtocol from airweave.models.source_connection import SourceConnection +from airweave.platform.configs.config import OutlookCalendarConfig, OutlookMailConfig, StripeConfig from airweave.schemas.source_connection import ( AuthenticationMethod, ScheduleConfig, @@ -40,6 +42,43 @@ ) +def _validate_outlook_capture_mode(source: SourceConnection, config: dict[str, Any]) -> None: + """Mutable legacy IDs and immutable originals cannot share an existing sync.""" + if source.short_name not in {"outlook_mail", "outlook_calendar"}: + return + config_type = ( + OutlookMailConfig if source.short_name == "outlook_mail" else OutlookCalendarConfig + ) + previous = config_type.model_validate(source.config_fields or {}) + proposed = config_type.model_validate(config) + if previous.capture_originals != proposed.capture_originals: + raise HTTPException( + status_code=400, + detail="Create a new source to change Outlook original capture mode", + ) + if ( + previous.capture_originals + and previous.expected_principal_id != proposed.expected_principal_id + ): + raise HTTPException( + status_code=400, + detail="Create a new source to change the Outlook original capture principal", + ) + + +def _validate_stripe_capture_binding(source: SourceConnection, config: dict[str, Any]) -> None: + """Account, API version, mode and capture representation require fresh identities.""" + if source.short_name != "stripe": + return + previous = StripeConfig.model_validate(source.config_fields or {}) + proposed = StripeConfig.model_validate(config) + if previous.original_capture != proposed.original_capture: + raise HTTPException( + status_code=400, + detail="Create a new source to change Stripe original capture binding", + ) + + class SourceConnectionUpdateService(SourceConnectionUpdateServiceProtocol): """Updates a source connection. @@ -93,6 +132,10 @@ async def update( if not source_conn: raise NotFoundException("Source connection not found") + await require_unmanaged_source( + db, id, ctx.organization.id, short_name=source_conn.short_name + ) + # Update fields update_data = obj_in.model_dump(exclude_unset=True) @@ -110,6 +153,8 @@ async def update( validated_config = self._source_validation.validate_config( source_conn.short_name, update_data["config"], ctx ) + _validate_outlook_capture_mode(source_conn, validated_config) + _validate_stripe_capture_binding(source_conn, validated_config) update_data["config_fields"] = validated_config del update_data["config"] @@ -233,6 +278,7 @@ async def _handle_schedule_update( collection_id=collection.id, collection_readable_id=collection.readable_id, source_entry=source_entry, + source_config=source_conn.config_fields, schedule_config=ScheduleConfig(cron=new_cron), run_immediately=False, ctx=ctx, diff --git a/backend/airweave/domains/sources/exceptions/__init__.py b/backend/airweave/domains/sources/exceptions/__init__.py index 04469e0c85..099f9c676f 100644 --- a/backend/airweave/domains/sources/exceptions/__init__.py +++ b/backend/airweave/domains/sources/exceptions/__init__.py @@ -13,6 +13,7 @@ SourceEntitySkippedError, SourceError, SourceFileDownloadError, + SourceGoneError, SourceNotFoundError, SourcePermanentError, SourceRateLimitError, diff --git a/backend/airweave/domains/sources/exceptions/_exceptions.py b/backend/airweave/domains/sources/exceptions/_exceptions.py index 9c30308351..d9d0c5b3f8 100644 --- a/backend/airweave/domains/sources/exceptions/_exceptions.py +++ b/backend/airweave/domains/sources/exceptions/_exceptions.py @@ -83,6 +83,10 @@ def __init__(self, message: str, *, source_short_name: str = ""): # --------------------------------------------------------------------------- +class SourceGoneError(SourceError): + """HTTP 410; the source decides whether a resource or continuation expired.""" + + class SourceCreationError(SourceError): """source_class.create() failed (bad credentials, missing fields, etc.).""" diff --git a/backend/airweave/domains/sources/exceptions/classifier.py b/backend/airweave/domains/sources/exceptions/classifier.py index a9bb853110..ed7da011ec 100644 --- a/backend/airweave/domains/sources/exceptions/classifier.py +++ b/backend/airweave/domains/sources/exceptions/classifier.py @@ -18,6 +18,8 @@ AuthProviderAccountNotFoundError, AuthProviderAuthError, AuthProviderError, + AuthProviderRateLimitError, + AuthProviderServerError, ) from airweave.domains.source_connections.types import ErrorClassification from airweave.domains.sources.exceptions import ( @@ -78,6 +80,13 @@ def classify_error(exc: Exception) -> ErrorClassification: # noqa: C901 message=str(exc), ) + if isinstance(exc, AuthProviderRateLimitError): + return ErrorClassification( + category=SourceConnectionErrorCategory.RATE_LIMITED, message=str(exc) + ) + if isinstance(exc, AuthProviderServerError): + return ErrorClassification(category=None, message=None) + # Catch-all for remaining AuthProviderError subtypes (e.g. MissingFieldsError, # ConfigError) — these are auth provider issues the user needs to address. if isinstance(exc, AuthProviderError): diff --git a/backend/airweave/domains/sources/lifecycle.py b/backend/airweave/domains/sources/lifecycle.py index cc568f47db..f548d902d0 100644 --- a/backend/airweave/domains/sources/lifecycle.py +++ b/backend/airweave/domains/sources/lifecycle.py @@ -54,6 +54,10 @@ TokenProviderAccountGoneError, ) from airweave.domains.sources.token_providers.oauth import OAuthTokenProvider +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + ManagedToolAuthProvider, +) from airweave.domains.sources.token_providers.static import StaticTokenProvider from airweave.domains.sources.types import AuthConfig, SourceConnectionData, SourceRegistryEntry from airweave.platform.http_client.airweave_client import AirweaveHttpClient @@ -159,40 +163,45 @@ async def create( source_connection_id=source_connection_data.source_connection_id, ctx=ctx, logger=logger, + managed_auth=auth_config.managed_auth, ) - # 5. Parse config_fields into typed config - entry = self._source_registry.get(source_connection_data.short_name) - config = self._build_typed_config(entry, source_connection_data.config_fields) - - # 6. Create source instance with all deps injected - source = await source_connection_data.source_class.create( - auth=token_provider, - logger=logger, - http_client=http_client, - config=config, - ) - - # 7. Validate credentials early so failures surface as NEEDS_REAUTH. - # Only catch auth-related exceptions — transient errors (server 5xx, - # rate limits, network timeouts) must propagate so they don't - # incorrectly pause schedules or set NEEDS_REAUTH status. try: - await source.validate() - except ( - SourceAuthError, - AuthProviderAuthError, - AuthProviderAccountNotFoundError, - TokenCredentialsInvalidError, - TokenExpiredError, - TokenProviderAccountGoneError, - ) as exc: - raise SourceValidationError( - short_name=source_connection_data.short_name, - reason=f"credential validation failed: {exc}", - ) from exc + # 5. Parse config_fields into typed config + entry = self._source_registry.get(source_connection_data.short_name) + config = self._build_typed_config(entry, source_connection_data.config_fields) - return source + # 6. Create source instance with all deps injected + source = await source_connection_data.source_class.create( + auth=token_provider, + logger=logger, + http_client=http_client, + config=config, + ) + + # 7. Validate credentials early so failures surface as NEEDS_REAUTH. + # Only catch auth-related exceptions — transient errors (server 5xx, + # rate limits, network timeouts) must propagate so they don't + # incorrectly pause schedules or set NEEDS_REAUTH status. + try: + await source.validate() + except ( + SourceAuthError, + AuthProviderAuthError, + AuthProviderAccountNotFoundError, + TokenCredentialsInvalidError, + TokenExpiredError, + TokenProviderAccountGoneError, + ) as exc: + raise SourceValidationError( + short_name=source_connection_data.short_name, + reason=f"credential validation failed: {exc}", + ) from exc + + return source + except BaseException: + await http_client.aclose() + raise async def validate( self, @@ -398,6 +407,7 @@ async def _get_auth_provider_configuration( return AuthConfig( credentials=auth_result.credentials, + managed_auth=auth_result.managed_auth, auth_provider_instance=auth_provider_instance, ) @@ -617,7 +627,7 @@ def _process_creds_dict( async def _resolve_token_provider( self, source_connection_data: SourceConnectionData, - source_credentials: SourceCredentials, + source_credentials: SourceCredentials | None, ctx: ApiContext, logger: ContextualLogger, access_token: Optional[str], @@ -634,6 +644,9 @@ async def _resolve_token_provider( auth_provider_instance: Optional[BaseAuthProvider] = auth_config.auth_provider_instance short_name = source_connection_data.short_name + if auth_config.managed_auth is not None: + return auth_config.managed_auth + if access_token is not None: return StaticTokenProvider(access_token, source_short_name=short_name) @@ -708,12 +721,24 @@ def _build_http_client( source_connection_id: UUID, ctx: ApiContext, logger: ContextualLogger, + managed_auth: ManagedAuthProvider | ManagedToolAuthProvider | None = None, ) -> AirweaveHttpClient: """Build an AirweaveHttpClient with rate limiting for this source.""" feature_enabled = ctx.has_feature(FeatureFlag.SOURCE_RATE_LIMITING) + from airweave.platform.http_client.composio_transport import ComposioTransport + + transport = ( + ComposioTransport( + api_key=managed_auth.api_key.get_secret_value(), + connected_account_id=managed_auth.connected_account_id, + allowed_hosts=managed_auth.allowed_hosts, + ) + if isinstance(managed_auth, ManagedAuthProvider) + else None + ) client = AirweaveHttpClient( - wrapped_client=httpx.AsyncClient(), + wrapped_client=httpx.AsyncClient(transport=transport), org_id=ctx.organization.id, source_short_name=source_short_name, rate_limiter=self._rate_limiter, diff --git a/backend/airweave/domains/sources/tests/test_validation.py b/backend/airweave/domains/sources/tests/test_validation.py index 85979a2cdd..fb7c9f8f18 100644 --- a/backend/airweave/domains/sources/tests/test_validation.py +++ b/backend/airweave/domains/sources/tests/test_validation.py @@ -65,3 +65,31 @@ def test_validate_config_passthrough_when_no_schema(): svc = SourceValidationService(source_registry=registry) payload: dict[str, Any] = {"x": 1} assert svc.validate_config("slack", payload, _make_ctx()) == payload + + +def test_calendar_config_is_json_serializable_and_round_trips(): + """Validated nested dates must survive source connection JSON persistence.""" + import json + + from airweave.platform.configs.config import GoogleCalendarConfig + + registry = FakeSourceRegistry() + registry.seed( + _make_entry("google_calendar", "Google Calendar").model_copy( + update={"config_ref": GoogleCalendarConfig} + ) + ) + config = { + "calendar_ids": ["owner@example.test"], + "occurrence_window": { + "start": "2026-10-01T00:00:00+05:30", + "end": "2026-10-08T00:00:00+05:30", + }, + } + validated = SourceValidationService(registry).validate_config( + "google_calendar", config, _make_ctx() + ) + stored = json.loads(json.dumps(validated)) + assert GoogleCalendarConfig.model_validate(stored) == GoogleCalendarConfig.model_validate( + config + ) diff --git a/backend/airweave/domains/sources/token_providers/protocol.py b/backend/airweave/domains/sources/token_providers/protocol.py index 26252ed78c..b813756bbc 100644 --- a/backend/airweave/domains/sources/token_providers/protocol.py +++ b/backend/airweave/domains/sources/token_providers/protocol.py @@ -16,7 +16,9 @@ """ from enum import Enum -from typing import Protocol, runtime_checkable +from typing import Literal, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, SecretStr class AuthProviderKind(str, Enum): @@ -81,3 +83,39 @@ async def force_refresh(self) -> str: TokenProviderServerError: If upstream fails during refresh. """ ... + + +class ManagedAuthProvider(BaseModel): + """Bound credential custody; deliberately does not expose a bearer token.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + api_key: SecretStr = Field(repr=False) + connected_account_id: str = Field(min_length=1) + allowed_hosts: frozenset[str] = Field(min_length=1) + provider_kind: Literal[AuthProviderKind.AUTH_PROVIDER] = AuthProviderKind.AUTH_PROVIDER + supports_refresh: Literal[False] = False + + +class ManagedToolAuthProvider(BaseModel): + """Explicit account-bound tool sessions for a provider without a REST proxy.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + api_key: SecretStr = Field(repr=False) + connected_account_id: str = Field(min_length=1) + user_id: str = Field(min_length=1) + provider_kind: Literal[AuthProviderKind.AUTH_PROVIDER] = AuthProviderKind.AUTH_PROVIDER + supports_refresh: Literal[False] = False + + +async def authorization_headers( + auth: SourceAuthProvider, *, refresh: bool = False +) -> dict[str, str]: + """Resolve direct credentials or explicitly defer authentication to transport.""" + if isinstance(auth, ManagedAuthProvider): + if refresh: + raise ValueError("Managed authentication refresh belongs to the auth provider") + return {} + if not isinstance(auth, TokenProviderProtocol): + raise TypeError("This request requires token or managed authentication") + token = await auth.force_refresh() if refresh else await auth.get_token() + return {"Authorization": f"Bearer {token}"} diff --git a/backend/airweave/domains/sources/types.py b/backend/airweave/domains/sources/types.py index d4013500b9..8f53d8947d 100644 --- a/backend/airweave/domains/sources/types.py +++ b/backend/airweave/domains/sources/types.py @@ -9,6 +9,10 @@ from airweave.core.protocols.registry import BaseRegistryEntry from airweave.domains.auth_provider._base import BaseAuthProvider from airweave.domains.credentials.types import DecryptedCredential +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + ManagedToolAuthProvider, +) from airweave.models.connection import Connection from airweave.models.source_connection import SourceConnection from airweave.platform.configs._base import BaseConfig, Fields @@ -91,3 +95,4 @@ class AuthConfig: credentials: Any auth_provider_instance: Optional[BaseAuthProvider] decrypted_credential: Optional[DecryptedCredential] = field(default=None) + managed_auth: ManagedAuthProvider | ManagedToolAuthProvider | None = None diff --git a/backend/airweave/domains/sources/validation.py b/backend/airweave/domains/sources/validation.py index 25edf4ecb3..fef969b934 100644 --- a/backend/airweave/domains/sources/validation.py +++ b/backend/airweave/domains/sources/validation.py @@ -31,7 +31,7 @@ def __init__(self, source_registry: SourceRegistryProtocol) -> None: def validate_config( self, short_name: str, config_fields: Mapping[str, Any] | None, ctx: ApiContext ) -> dict[str, Any]: - """Validate configuration fields against source schema, returning a plain dict. + """Validate source configuration and return JSON-compatible persistence fields. Also strips fields that have feature flags not enabled for the organization. """ @@ -48,7 +48,7 @@ def validate_config( try: model = config_class.model_validate(payload) - return model.model_dump() + return model.model_dump(mode="json") except ValidationError as e: errors = "; ".join([f"{self._loc(err)}: {err.get('msg')}" for err in e.errors()]) raise HTTPException(status_code=422, detail=f"Invalid config fields: {errors}") from e diff --git a/backend/airweave/domains/storage/exceptions.py b/backend/airweave/domains/storage/exceptions.py index dfffed4272..5fe09cac2c 100644 --- a/backend/airweave/domains/storage/exceptions.py +++ b/backend/airweave/domains/storage/exceptions.py @@ -55,3 +55,7 @@ def __init__(self, reason: str, filename: str): self.reason = reason self.filename = filename super().__init__(f"File '{filename}' skipped: {reason}") + + +class StorageReadLimitExceeded(StorageException): + """Actual stored object exceeds the explicitly requested read bound.""" diff --git a/backend/airweave/domains/storage/file_service.py b/backend/airweave/domains/storage/file_service.py index 945ebf40d6..5500d0974c 100644 --- a/backend/airweave/domains/storage/file_service.py +++ b/backend/airweave/domains/storage/file_service.py @@ -10,6 +10,7 @@ Raw httpx errors propagate to the caller. """ +import hashlib import os import shutil from typing import Optional @@ -20,14 +21,20 @@ from tenacity import retry, stop_after_attempt from airweave.core.logging import ContextualLogger -from airweave.domains.sources.token_providers.protocol import SourceAuthProvider +from airweave.domains.entities.canonical.requests import BlobReference +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES from airweave.domains.storage.paths import paths from airweave.domains.storage.protocols import StorageBackend from airweave.domains.sync_pipeline.file_types import SUPPORTED_FILE_EXTENSIONS from airweave.platform.entities._base import FileEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) @@ -37,19 +44,87 @@ class FileService: """Unified file service for downloading and restoring files.""" - MAX_FILE_SIZE_BYTES = 209715200 + MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_BYTES def __init__( self, sync_job_id: UUID, storage_backend: StorageBackend, + *, + sync_id: UUID | None = None, ) -> None: """Initialize file service.""" self.sync_job_id = sync_job_id + self.sync_id = sync_id self.storage = storage_backend self.base_temp_dir = paths.temp_sync_dir(sync_job_id) self._ensure_base_dir() + async def store_canonical_blob( + self, content: bytes, *, media_type: str | None = None + ) -> BlobReference: + """Persist immutable source bytes before publishing their record reference.""" + if self.sync_id is None: + raise ValueError("Canonical blob storage requires a sync identity") + if len(content) > self.MAX_FILE_SIZE_BYTES: + raise ValueError("Canonical blob exceeds the file size limit") + digest = hashlib.sha256(content).hexdigest() + key = f"canonical/{self.sync_id}/blobs/sha256/{digest}" + await self.storage.write_file(key, content) + return BlobReference(key=key, sha256=digest, size_bytes=len(content), media_type=media_type) + + async def capture_canonical_url( + self, + url: str, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + logger: ContextualLogger, + media_type: str | None = None, + *, + follow_redirects: bool = True, + expected_media_type: str | None = None, + ) -> BlobReference: + """Capture original bytes with size/auth checks, independent of search file types.""" + if self.sync_id is None: + raise ValueError("Canonical blob storage requires a sync identity") + validate_url(url) + headers = await self._resolve_headers(auth, url) + temp_path = f"{self.base_temp_dir}/{uuid4()}-canonical" + try: + try: + await self._stream_download( + client, + url, + headers, + temp_path, + logger, + follow_redirects=follow_redirects, + expected_media_type=expected_media_type, + ) + except httpx.HTTPStatusError as exc: + if ( + exc.response.status_code != 401 + or not headers.get("Authorization") + or not auth.supports_refresh + ): + raise + # Never promote an unauthenticated signed URL to provider credentials. + headers = await authorization_headers(auth, refresh=True) + await self._stream_download( + client, + url, + headers, + temp_path, + logger, + follow_redirects=follow_redirects, + expected_media_type=expected_media_type, + ) + async with aiofiles.open(temp_path, "rb") as downloaded: + content = await downloaded.read(self.MAX_FILE_SIZE_BYTES + 1) + return await self.store_canonical_blob(content, media_type=media_type) + finally: + self._cleanup_temp(temp_path) + def _ensure_base_dir(self) -> None: """Ensure temp directory exists.""" os.makedirs(self.base_temp_dir, exist_ok=True) @@ -61,12 +136,11 @@ def _ensure_base_dir(self) -> None: @staticmethod async def _resolve_headers(auth: SourceAuthProvider, url: str) -> dict: """Build auth headers. Pre-signed URLs skip the bearer token.""" + if isinstance(auth, ManagedAuthProvider): + return {} if "X-Amz-Algorithm" in url or "tempauth=" in url: return {} - token = await auth.get_token() if hasattr(auth, "get_token") else None - if not token: - raise ValueError(f"No access token available for downloading {url}") - return {"Authorization": f"Bearer {token}"} + return await authorization_headers(auth) # ========================================================================= # URL Download @@ -115,16 +189,31 @@ async def _stream_download( headers: dict, temp_path: str, logger: ContextualLogger, + *, + follow_redirects: bool = True, + expected_media_type: str | None = None, ) -> None: """Stream-download a file to disk with retry on 429/5xx/timeout.""" async with client.stream( "GET", url, headers=headers, - follow_redirects=True, + follow_redirects=follow_redirects, timeout=httpx.Timeout(180.0, read=540.0), ) as response: response.raise_for_status() + if expected_media_type is not None: + actual_type = ( + response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + ) + expected_type = expected_media_type.split(";", 1)[0].strip().lower() + if not actual_type or actual_type not in { + expected_type, + "application/octet-stream", + }: + raise ValueError( + "Downloaded file content type does not match provider metadata" + ) content_length = response.headers.get("Content-Length") if content_length and int(content_length) > self.MAX_FILE_SIZE_BYTES: @@ -188,10 +277,13 @@ async def download_from_url( self._cleanup_temp(temp_path) raise except httpx.HTTPStatusError as first_error: - if first_error.response.status_code == 401 and auth.supports_refresh: + if ( + first_error.response.status_code == 401 + and headers.get("Authorization") + and auth.supports_refresh + ): logger.info("Download got 401, refreshing token and retrying") - new_token = await auth.force_refresh() - headers = {"Authorization": f"Bearer {new_token}"} + headers = await authorization_headers(auth, refresh=True) try: await self._stream_download(client, entity.url, headers, temp_path, logger) except Exception: @@ -201,6 +293,10 @@ async def download_from_url( self._cleanup_temp(temp_path) raise + except BaseException: + self._cleanup_temp(temp_path) + raise + logger.debug(f"Downloaded file to: {temp_path}") entity.local_path = temp_path return entity diff --git a/backend/airweave/domains/storage/limits.py b/backend/airweave/domains/storage/limits.py new file mode 100644 index 0000000000..d21d76d1db --- /dev/null +++ b/backend/airweave/domains/storage/limits.py @@ -0,0 +1,3 @@ +"""Shared byte ceilings for captured files and their projection inputs.""" + +MAX_FILE_SIZE_BYTES = 209715200 diff --git a/backend/airweave/domains/storage/protocols.py b/backend/airweave/domains/storage/protocols.py index f36c7d2c84..c2d1973707 100644 --- a/backend/airweave/domains/storage/protocols.py +++ b/backend/airweave/domains/storage/protocols.py @@ -66,11 +66,12 @@ async def write_file(self, path: str, content: bytes) -> None: """ ... - async def read_file(self, path: str) -> bytes: - """Read binary content from storage. + async def read_file(self, path: str, *, max_bytes: int | None = None) -> bytes: + """Read binary content from storage; optional bound stops reads at max_bytes + 1. Args: path: Relative path + max_bytes: Optional maximum actual bytes; oversized objects raise a storage error. Returns: Binary content @@ -91,6 +92,14 @@ async def exists(self, path: str) -> bool: """ ... + async def delete_file(self, path: str) -> None: + """Delete exactly one object; absence succeeds, other failures raise. + + Never fall back to prefix/directory deletion. Backend retention/versioning + policies still govern physical historical-byte reclamation. + """ + ... + async def delete(self, path: str) -> bool: """Delete a file or directory. diff --git a/backend/airweave/domains/storage/tests/test_exact_delete.py b/backend/airweave/domains/storage/tests/test_exact_delete.py new file mode 100644 index 0000000000..d92098c783 --- /dev/null +++ b/backend/airweave/domains/storage/tests/test_exact_delete.py @@ -0,0 +1,79 @@ +"""Strict exact-object deletion using installed SDK errors, without cloud calls.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError +from botocore.exceptions import ClientError +from google.cloud.exceptions import Forbidden, NotFound + +from airweave.adapters.storage.aws_s3 import S3Backend +from airweave.adapters.storage.azure_blob import AzureBlobBackend +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.adapters.storage.gcp_gcs import GCSBackend +from airweave.domains.storage.exceptions import StorageException + +pytestmark = pytest.mark.asyncio + + +async def test_filesystem_exact_delete_is_idempotent_and_never_recursive(tmp_path, monkeypatch): + storage = FilesystemBackend(tmp_path) + await storage.write_file("one", b"body") + await storage.delete_file("one") + await storage.delete_file("one") + await storage.write_file("folder/child", b"keep") + with pytest.raises(StorageException): + await storage.delete_file("folder") + assert (tmp_path / "folder/child").read_bytes() == b"keep" + monkeypatch.setattr("aiofiles.os.remove", AsyncMock(side_effect=PermissionError("private"))) + with pytest.raises(StorageException, match="^Exact file deletion failed$"): + await storage.delete_file("folder/child") + + +async def test_s3_delete_uses_only_exact_key_and_preserves_errors(): + storage = S3Backend("bucket", "us-east-1", prefix="owned") + client = Mock(delete_object=AsyncMock()) + storage._get_client = AsyncMock(return_value=client) + await storage.delete_file("key") + client.delete_object.assert_awaited_once_with(Bucket="bucket", Key="owned/key") + client.delete_object.side_effect = ClientError({"Error": {"Code": "NoSuchKey"}}, "DeleteObject") + await storage.delete_file("key") + client.delete_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied"}}, "DeleteObject" + ) + with pytest.raises(StorageException): + await storage.delete_file("key") + client.head_object.assert_not_called() + client.get_paginator.assert_not_called() + + +async def test_azure_delete_uses_only_exact_blob_and_preserves_errors(): + storage = AzureBlobBackend("account", "container", prefix="owned") + blob = Mock(delete_blob=AsyncMock()) + container = Mock(get_blob_client=Mock(return_value=blob)) + storage._get_container_client = AsyncMock(return_value=container) + await storage.delete_file("key") + container.get_blob_client.assert_called_once_with("owned/key") + blob.delete_blob.assert_awaited_once_with() + blob.delete_blob.side_effect = ResourceNotFoundError("missing") + await storage.delete_file("key") + blob.delete_blob.side_effect = HttpResponseError("private") + with pytest.raises(StorageException): + await storage.delete_file("key") + container.list_blobs.assert_not_called() + + +async def test_gcs_delete_uses_only_exact_blob_and_preserves_errors(): + storage = GCSBackend("bucket", prefix="owned") + blob = Mock(delete=Mock()) + bucket = Mock(blob=Mock(return_value=blob)) + storage._get_bucket = Mock(return_value=bucket) + await storage.delete_file("key") + bucket.blob.assert_called_once_with("owned/key") + blob.delete.assert_called_once_with() + blob.delete.side_effect = NotFound("missing") + await storage.delete_file("key") + blob.delete.side_effect = Forbidden("private") + with pytest.raises(StorageException): + await storage.delete_file("key") + bucket.list_blobs.assert_not_called() diff --git a/backend/airweave/domains/storage/tests/test_file_service.py b/backend/airweave/domains/storage/tests/test_file_service.py index 65637d0309..3cbbfa6878 100644 --- a/backend/airweave/domains/storage/tests/test_file_service.py +++ b/backend/airweave/domains/storage/tests/test_file_service.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 +import httpx import pytest from airweave.domains.storage.exceptions import FileSkippedException @@ -19,9 +20,7 @@ def _make_service(tmpdir: str) -> FileService: storage.write_file = AsyncMock() storage.delete_directory = AsyncMock() - with patch( - "airweave.domains.storage.file_service.paths.temp_sync_dir", return_value=tmpdir - ): + with patch("airweave.domains.storage.file_service.paths.temp_sync_dir", return_value=tmpdir): svc = FileService(sync_job_id=sync_job_id, storage_backend=storage) return svc, storage @@ -128,3 +127,170 @@ async def test_does_not_raise_when_dir_already_missing(self): # tmpdir has been deleted by the context manager exit await svc.cleanup_sync_directory(logger=MagicMock()) + + +class TestCanonicalBlobs: + @pytest.mark.asyncio + async def test_canonical_capture_keeps_original_bytes_and_cleans_temp(self, tmp_path): + from airweave.domains.sources.token_providers.static import StaticTokenProvider + + svc, storage = _make_service(str(tmp_path)) + svc.sync_id = uuid4() + content = b"original unsupported format" + + async def download( + client, + url, + headers, + destination, + logger, + *, + follow_redirects=True, + expected_media_type=None, + ): + with open(destination, "wb") as output: + output.write(content) + + svc._stream_download = download + blob = await svc.capture_canonical_url( + "https://example.com/file.unknown", + MagicMock(), + StaticTokenProvider("token"), + MagicMock(), + ) + assert blob.key.startswith(f"canonical/{svc.sync_id}/blobs/sha256/") + storage.write_file.assert_awaited_once_with(blob.key, content) + assert not list(tmp_path.iterdir()) + + @pytest.mark.asyncio + async def test_storage_failure_does_not_publish_reference(self, tmp_path): + svc, storage = _make_service(str(tmp_path)) + svc.sync_id = uuid4() + storage.write_file.side_effect = OSError("storage down") + with pytest.raises(OSError, match="storage down"): + await svc.store_canonical_blob(b"bytes") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("canonical", [True, False]) +@pytest.mark.parametrize("signed", [True, False]) +async def test_401_refresh_requires_original_bearer_request(tmp_path, canonical, signed): + from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + + auth = MagicMock(spec=TokenProviderProtocol) + auth.supports_refresh = True + auth.get_token = AsyncMock(return_value="fixture-old") + auth.force_refresh = AsyncMock(return_value="fixture-new") + requests = [] + + async def download(request): + if request.method == "HEAD": + return httpx.Response(200) + requests.append(request.headers.get("Authorization")) + return httpx.Response(401 if len(requests) == 1 else 200, content=b"original") + + service, storage = _make_service(str(tmp_path)) + service.sync_id = uuid4() + url = "https://files.example/original.pdf" + if signed: + url += "?X-Amz-Algorithm=fixture" + async with httpx.AsyncClient(transport=httpx.MockTransport(download)) as raw: + client = AirweaveHttpClient(raw, uuid4(), "fixture", feature_flag_enabled=False) + if canonical: + operation = service.capture_canonical_url(url, client, auth, MagicMock()) + else: + entity = MagicMock(name="entity") + entity.name, entity.url = "original.pdf", url + operation = service.download_from_url(entity, client, auth, MagicMock()) + if signed: + with pytest.raises(httpx.HTTPStatusError): + await operation + auth.force_refresh.assert_not_awaited() + assert requests == [None] + storage.write_file.assert_not_awaited() + assert not list(tmp_path.iterdir()) + else: + await operation + auth.force_refresh.assert_awaited_once() + assert requests == ["Bearer fixture-old", "Bearer fixture-new"] + + +@pytest.mark.asyncio +async def test_canonical_can_reject_redirect_without_storing_its_body(tmp_path): + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + + service, storage = _make_service(str(tmp_path)) + service.sync_id = uuid4() + calls = [] + + async def download(request): + if request.method == "HEAD": + return httpx.Response(200) + calls.append(request.url.path) + if request.url.path == "/original.pdf": + return httpx.Response(302, headers={"location": "/target.pdf"}, content=b"redirect") + return httpx.Response(200, content=b"original") + + async with httpx.AsyncClient(transport=httpx.MockTransport(download)) as raw: + client = AirweaveHttpClient(raw, uuid4(), "fixture", feature_flag_enabled=False) + with pytest.raises(httpx.HTTPStatusError) as error: + await service.capture_canonical_url( + "https://files.example/original.pdf", + client, + StaticTokenProvider("fixture"), + MagicMock(), + follow_redirects=False, + ) + assert error.value.response.status_code == 302 + assert calls == ["/original.pdf"] + storage.write_file.assert_not_awaited() + assert not list(tmp_path.iterdir()) + # Existing callers retain their redirect behavior without a new argument. + blob = await service.capture_canonical_url( + "https://files.example/original.pdf", + client, + StaticTokenProvider("fixture"), + MagicMock(), + ) + assert calls == ["/original.pdf", "/original.pdf", "/target.pdf"] + storage.write_file.assert_awaited_once_with(blob.key, b"original") + + entity = MagicMock(name="entity") + entity.name, entity.url = "original.pdf", "https://files.example/original.pdf" + downloaded = await service.download_from_url( + entity, client, StaticTokenProvider("fixture"), MagicMock() + ) + assert calls[-2:] == ["/original.pdf", "/target.pdf"] + with open(downloaded.local_path, "rb") as original: + assert original.read() == b"original" + + +@pytest.mark.asyncio +async def test_expected_file_type_rejects_login_html_but_accepts_binary(tmp_path): + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + + service, storage = _make_service(str(tmp_path)) + service.sync_id = uuid4() + responses = [ + httpx.Response(200, headers={"Content-Type": "text/html; charset=utf-8"}, content=b"login"), + httpx.Response( + 200, headers={"Content-Type": "application/octet-stream"}, content=b"original" + ), + ] + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: responses.pop(0))) as raw: + client = AirweaveHttpClient(raw, uuid4(), "fixture", feature_flag_enabled=False) + args = ( + "https://files.example/original.pdf", + client, + StaticTokenProvider("fixture"), + MagicMock(), + ) + with pytest.raises(ValueError, match="content type does not match"): + await service.capture_canonical_url(*args, expected_media_type="application/pdf") + storage.write_file.assert_not_awaited() + assert not list(tmp_path.iterdir()) + blob = await service.capture_canonical_url(*args, expected_media_type="application/pdf") + storage.write_file.assert_awaited_once_with(blob.key, b"original") diff --git a/backend/airweave/domains/sync_pipeline/canonical_capture.py b/backend/airweave/domains/sync_pipeline/canonical_capture.py new file mode 100644 index 0000000000..bf8c34faf5 --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/canonical_capture.py @@ -0,0 +1,365 @@ +"""Capture pipeline: native records commit before independent search projection.""" + +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.core.events.sync import EntityBatchProcessedEvent, TypeActionCounts +from airweave.core.protocols.event_bus import EventBus +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CompleteCycle, + CycleConfiguration, + TerminalCheckpoint, +) +from airweave.domains.entities.canonical.models import CaptureResult +from airweave.domains.entities.canonical.page_source import ( + CanonicalPageSource, + CheckpointedPageSource, + ScopedPageSource, +) +from airweave.domains.entities.canonical.requests import ( + CaptureBatch, + CaptureRecord, + CompletedScope, + ReconcileScope, + RecordIdentity, + RemovedScope, + StartedScope, + WriterFence, +) +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.source import SourceObservation +from airweave.domains.storage.file_service import FileService +from airweave.domains.sync_pipeline.canonical_scan import CanonicalScanDriver +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.domains.sync_pipeline.contexts import SyncContext +from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime +from airweave.domains.sync_pipeline.exceptions import SyncFailureError +from airweave.domains.sync_pipeline.pipeline.cleanup_service import cleanup_service + + +class CanonicalCapturePipeline: + """One source writer, one record authority; no legacy sink dispatcher participates.""" + + def __init__( + self, + service: CanonicalCaptureService, + sessions: Callable[[], AbstractAsyncContextManager[AsyncSession]], + event_bus: EventBus, + record_types: tuple[str, ...], + attempt: CaptureAttempt, + container_parents: dict[str, str | tuple[str | None, ...]] | None = None, + page_source: CanonicalPageSource | None = None, + files: FileService | None = None, + ): + """Inject transactional persistence and existing progress-event infrastructure.""" + if not record_types: + raise ValueError("Canonical source must declare at least one audited record type") + self._service = service + self._sessions = sessions + self._event_bus = event_bus + self._record_types = frozenset(record_types) + self._container_parents = dict(container_parents or {}) + self.page_source = page_source + self.files = files + self._cycle: CaptureCycle | None = None + if page_source is not None: + config = page_source.capture_cycle_configuration + declared = CycleConfiguration.from_source( + fingerprint=config.fingerprint, + record_types=record_types, + container_parents=self._container_parents, + completion_policies=config.completion_policies, + known_object_validation=config.known_object_validation, + scope_changes=config.scope_changes, + exact_parent_validation=config.exact_parent_validation, + ) + if config != declared: + raise ValueError("Page source cycle must match its declared container topology") + else: + for child, parent in self._container_parents.items(): + if ( + child not in self._record_types + or not isinstance(parent, str) + or parent not in self._record_types + ): + raise ValueError( + "Container relationships must name declared canonical record types" + ) + if parent in self._container_parents: + raise ValueError("Nested sources require the durable page capability") + self._attempt = attempt + self._fence: WriterFence | None = None + self._completed_scopes: set[CompletedScope] = set() + self._batch_sequence = 0 + self._removed_scopes: set[CompletedScope] = set() + + async def start(self, sync_context: SyncContext) -> None: + """Fence the activity after RUNNING transition and before fetching provider data.""" + async with self._sessions() as db: + self._fence = await self._service.activate_writer( + db, + sync_context.organization_id, + sync_context.sync_id, + sync_context.sync_job_id, + attempt_id=self._attempt.id, + attempt_number=self._attempt.number, + ) + + async def run_scans( + self, + sync_context: SyncContext, + runtime: SyncRuntime, + check_limits: Callable[[], Awaitable[None]], + ) -> None: + """Run opted-in pages with a commit barrier before every next provider call.""" + if self.files is None: + raise SyncFailureError("Page capture requires the source file service") + if self.page_source is None: + raise SyncFailureError("Source has no durable page capability") + if sync_context.execution_config.cursor.skip_updates: + raise SyncFailureError("Durable page capture cannot disable checkpoint updates") + + async def progress(result: CaptureResult, records: tuple[CaptureRecord, ...]) -> None: + for record in records: + await runtime.entity_tracker.track_entity( + record.identity.record_type, record.identity.entity_key + ) + await self._record_progress(result, sync_context, runtime) + + self._cycle = await CanonicalScanDriver( + self._service, + self._sessions, + self._writer(), + self.page_source, + progress, + check_limits, + self.files, + force_full=( + sync_context.force_full_sync or sync_context.execution_config.cursor.skip_load + ) + if isinstance(self.page_source, (CheckpointedPageSource, ScopedPageSource)) + else False, + ).run() + + def _writer(self) -> WriterFence: + if self._fence is None: + raise SyncFailureError("Canonical capture was not activated") + return self._fence + + async def process( + self, entities: list[SourceObservation], sync_context: SyncContext, runtime: SyncRuntime + ) -> None: + """Persist original source observations in stream order; never infer raw data.""" + records: list[CaptureRecord] = [] + for observation in entities: + if isinstance(observation, CaptureRecord): + record_type = observation.identity.record_type + scope = CompletedScope( + record_type=record_type, container_id=observation.identity.container_id + ) + if scope in self._removed_scopes: + raise SyncFailureError("Source yielded a record after confirmed scope loss") + records.append(self._with_parent(observation)) + elif isinstance(observation, (StartedScope, RemovedScope, CompletedScope)): + record_type = observation.record_type + else: + raise SyncFailureError("Canonical source yielded a non-observation value") + if record_type not in self._record_types: + raise SyncFailureError(f"Undeclared canonical record type: {record_type}") + if isinstance(observation, CaptureRecord): + continue + # A lifecycle marker takes effect after all prior records and before following ones. + await self._capture_records(records, sync_context, runtime) + records = [] + await self._apply_scope(observation, sync_context, runtime) + await self._capture_records(records, sync_context, runtime) + + def _with_parent(self, record: CaptureRecord) -> CaptureRecord: + parent_type = self._container_parents.get(record.identity.record_type) + if parent_type is None: + return record + if record.identity.container_id is None: + raise SyncFailureError("Container-scoped canonical record is missing its container ID") + parent = RecordIdentity(record_type=parent_type, native_id=record.identity.container_id) + if record.parent is not None and record.parent != parent: + raise SyncFailureError("Record parent conflicts with its declared container identity") + return record.model_copy(update={"parent": parent}) + + async def _apply_scope( + self, + observation: StartedScope | RemovedScope | CompletedScope, + sync_context: SyncContext, + runtime: SyncRuntime, + ) -> None: + """Apply a scope boundary after earlier records have committed.""" + scope = CompletedScope( + record_type=observation.record_type, container_id=observation.container_id + ) + if isinstance(observation, StartedScope): + self._completed_scopes.discard(scope) + self._removed_scopes.discard(scope) + async with self._sessions() as db: + await self._service.start_scope(db, self._writer(), observation) + elif isinstance(observation, RemovedScope): + self._completed_scopes.discard(scope) + self._removed_scopes.add(scope) + while True: + async with self._sessions() as db: + result = await self._service.remove_scope(db, self._writer(), observation) + if result.capture.changes: + await self._record_progress(result.capture, sync_context, runtime) + if not result.has_more: + break + else: + if scope in self._removed_scopes: + raise SyncFailureError("Removed scope cannot be marked complete without a new scan") + self._completed_scopes.add(scope) + + async def _capture_records( + self, records: list[CaptureRecord], sync_context: SyncContext, runtime: SyncRuntime + ) -> None: + # Keep bounded DB transactions independently of stream micro-batch tuning. + for offset in range(0, len(records), 500): + chunk = tuple(records[offset : offset + 500]) + async with self._sessions() as db: + result = await self._service.capture( + db, CaptureBatch(fence=self._writer(), records=chunk) + ) + for record in chunk: + await runtime.entity_tracker.track_entity( + record.identity.record_type, record.identity.entity_key + ) + await self._record_progress(result, sync_context, runtime) + + async def _record_progress( + self, result: CaptureResult, ctx: SyncContext, runtime: SyncRuntime + ) -> None: + counts: dict[str, TypeActionCounts] = {} + inserted = updated = deleted = 0 + for change in result.changes: + record_type = change.record.identity.record_type + old = counts.get(record_type, TypeActionCounts()) + if change.kind == "delete": + deleted += 1 + counts[record_type] = old.model_copy(update={"deleted": old.deleted + 1}) + await runtime.entity_tracker.record_deletes(record_type) + elif change.record.revision == 1: + inserted += 1 + counts[record_type] = old.model_copy(update={"inserted": old.inserted + 1}) + await runtime.entity_tracker.record_inserts(record_type) + else: + updated += 1 + counts[record_type] = old.model_copy(update={"updated": old.updated + 1}) + await runtime.entity_tracker.record_updates(record_type) + await runtime.entity_tracker.record_kept(result.unchanged) + self._batch_sequence += 1 + await self._event_bus.publish( + EntityBatchProcessedEvent( + organization_id=ctx.organization_id, + sync_id=ctx.sync_id, + sync_job_id=ctx.sync_job_id, + collection_id=ctx.collection_id, + source_connection_id=ctx.source_connection_id, + source_type=ctx.source_short_name, + inserted=inserted, + updated=updated, + deleted=deleted, + kept=result.unchanged, + type_breakdown=counts, + batch_seq=self._batch_sequence, + ) + ) + + async def cleanup_orphaned_entities( + self, sync_context: SyncContext, runtime: SyncRuntime + ) -> None: + """Reconcile only explicit successful scopes after the entire capture barrier.""" + for scope in sorted( + self._completed_scopes, key=lambda s: (s.record_type, s.container_id or "") + ): + while True: + async with self._sessions() as db: + result = await self._service.reconcile_scope( + db, + ReconcileScope( + fence=self._writer(), + scope=scope, + # Missing containers withdraw content scope; absence does + # not prove provider deletion or credential revocation. + removal_reason=( + "scope_removed" + if scope.record_type in self._container_parents.values() + else "absent" + ), + observed_at=datetime.now(timezone.utc), + ), + ) + if result.capture.changes: + await self._record_progress(result.capture, sync_context, runtime) + if not result.has_more: + break + await self._reconcile_parents(sync_context, runtime) + + async def _reconcile_parents(self, sync_context: SyncContext, runtime: SyncRuntime) -> None: + while True: + async with self._sessions() as db: + result = await self._service.reconcile_parents(db, self._writer()) + if result.capture.changes: + await self._record_progress(result.capture, sync_context, runtime) + if not result.has_more: + break + + async def save_checkpoint(self, sync_context: SyncContext, runtime: SyncRuntime) -> None: + """Commit cursor only after source success, durable captures and scoped reconciliation.""" + if self.page_source is not None: + if self._cycle is None: + raise SyncFailureError("Page capture did not reach its completion barrier") + async with self._sessions() as db: + current = await self._service.read_cycle(db, self._writer()) + if current != self._cycle: + raise SyncFailureError("Cycle changed before source finalization") + if current.phase == "complete": + if current.completed_job_id != self._writer().job_id: + raise SyncFailureError("Completed cycle belongs to another job") + return + terminal = await self._terminal_checkpoint(current) + async with self._sessions() as db: + self._cycle = await self._service.complete_cycle( + db, + CompleteCycle( + fence=self._writer(), expected=current.version, terminal_checkpoint=terminal + ), + ) + return + if runtime.cursor is None or not runtime.cursor.cursor_data: + return + if sync_context.execution_config and sync_context.execution_config.cursor.skip_updates: + return + async with self._sessions() as db: + await self._service.save_checkpoint(db, self._writer(), runtime.cursor.cursor_data) + + async def _terminal_checkpoint(self, current: CaptureCycle) -> TerminalCheckpoint | None: + """Read only the committed terminal root boundary; the store reattests it at publication.""" + terminal = None + if current.starting_checkpoint is not None or current.mode == "changes": + if len(current.configuration.root_record_types) != 1: + raise SyncFailureError("Checkpoint capture requires one terminal root scope") + kind = current.configuration.root_record_types[0] + async with self._sessions() as db: + scan = await self._service.read_scan( + db, self._writer(), CompletedScope(record_type=kind) + ) + if scan is None or scan.provider_checkpoint is None: + raise SyncFailureError("Capture has no committed terminal provider checkpoint") + terminal = TerminalCheckpoint( + record_type=kind, expected=scan.version, checkpoint=scan.provider_checkpoint + ) + return terminal + + async def cleanup_temp_files(self, sync_context: SyncContext, runtime: SyncRuntime) -> None: + """Reuse source-job temporary-file cleanup; immutable blobs are not temporary.""" + await cleanup_service.cleanup_temp_files(sync_context, runtime) diff --git a/backend/airweave/domains/sync_pipeline/canonical_scan.py b/backend/airweave/domains/sync_pipeline/canonical_scan.py new file mode 100644 index 0000000000..42573091d6 --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/canonical_scan.py @@ -0,0 +1,516 @@ +"""Sequential page driver inside the existing capture job, without provider prefetch.""" + +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from airweave.domains.entities.canonical.cycle_models import ( + BeginCycle, + CaptureCycle, + RestartCycle, + ScopeWork, +) +from airweave.domains.entities.canonical.cycle_store import CycleConflict +from airweave.domains.entities.canonical.models import CaptureResult, SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CanonicalPageSource, + CapturePlan, + CheckpointedPageSource, + InvalidCaptureCheckpoint, + InvalidScanContinuation, + InvalidScopeCheckpoint, + KnownObjectSource, + RequiredScopeAccessLost, + ScopeAccessLost, + ScopedPageSource, + ScopeRemovalReason, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + WriterFence, +) +from airweave.domains.entities.canonical.scan_models import ( + BeginScan, + CommitOmission, + CommitScanPage, + ReconcileScan, + ScanAdmission, + ScanContinuation, + ScanState, +) +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.storage.file_service import FileService + + +class _ChildScopeAccessLost(Exception): + """A required child failure whose owner has already been withdrawn.""" + + def __init__(self, error: RequiredScopeAccessLost) -> None: + super().__init__(str(error)) + self.error = error + + +class CanonicalScanDriver: + """Provider calls occur between existing fenced service transactions, never within one.""" + + def __init__( + self, + service: CanonicalCaptureService, + sessions: Callable[[], AbstractAsyncContextManager[AsyncSession]], + fence: WriterFence, + source: CanonicalPageSource, + progress: Callable[[CaptureResult, tuple[CaptureRecord, ...]], Awaitable[None]], + check_limits: Callable[[], Awaitable[None]], + files: FileService, + *, + force_full: bool = False, + ): + """Reuse the capture service and pipeline progress/guard callbacks.""" + self.service, self.sessions, self.fence, self.source = service, sessions, fence, source + self.files = files + self.force_full = force_full + self.progress, self.check_limits = progress, check_limits + self._restarted_scopes: set[tuple[UUID, CompletedScope]] = set() + + async def run(self) -> CaptureCycle: + """Refresh membership first, then resume its currently visible child scopes.""" + configuration = self.source.capture_cycle_configuration + async with self.sessions() as db: + current = await self.service.read_cycle(db, self.fence) + if current is not None and current.phase == "complete": + if current.completed_job_id == self.fence.job_id: + return current + expected = current.version + else: + expected = None + if ( + self.force_full + and isinstance(self.source, (CheckpointedPageSource, ScopedPageSource)) + and current is not None + and current.phase == "active" + and ( + current.mode == "changes" + or (current.mode == "mixed" and not current.force_full_scopes) + or current.configuration != configuration + ) + ): + cycle = await self.fresh_full(current) + else: + plan = ( + CapturePlan( + mode=current.mode, + starting_checkpoint=current.starting_checkpoint, + source_plan=current.source_plan, + ) + if current is not None and current.phase == "active" + else ( + await self.source.prepare_cycle(None if self.force_full else current) + if isinstance(self.source, (CheckpointedPageSource, ScopedPageSource)) + else CapturePlan() + ) + ) + async with self.sessions() as db: + cycle = await self.service.begin_cycle( + db, + BeginCycle( + fence=self.fence, + configuration=configuration, + expected=expected, + mode=plan.mode, + starting_checkpoint=plan.starting_checkpoint, + source_plan=plan.source_plan, + force_full_scopes=( + current.force_full_scopes + if current and current.phase == "active" + else self.force_full + ) + if plan.mode == "mixed" + else False, + ), + ) + for restart in range(2): + try: + return await self.run_cycle(cycle) + except InvalidCaptureCheckpoint: + if restart or not isinstance( + self.source, (CheckpointedPageSource, ScopedPageSource) + ): + raise + await self.check_limits() + cycle = await self.fresh_full(cycle) + raise AssertionError("Unreachable checkpoint recovery") + + async def fresh_full(self, previous: CaptureCycle) -> CaptureCycle: + """Explicit full requests and native expiry share the same exact-cycle restart.""" + if not isinstance(self.source, (CheckpointedPageSource, ScopedPageSource)): + raise CycleConflict("Source has no checkpoint recovery contract") + plan = await self.source.prepare_cycle(None) + if plan.mode != "full" and not ( + plan.mode == "mixed" and isinstance(self.source, ScopedPageSource) + ): + raise CycleConflict("Checkpoint recovery requires a fresh full capture") + async with self.sessions() as db: + current = await self.service.read_cycle(db, self.fence) + if current is None or current.version.cycle_id != previous.version.cycle_id: + raise CycleConflict("Cycle changed during checkpoint recovery") + return await self.service.restart_cycle( + db, + RestartCycle( + fence=self.fence, + expected=current.version, + configuration=self.source.capture_cycle_configuration, + mode=plan.mode, + starting_checkpoint=plan.starting_checkpoint, + source_plan=plan.source_plan, + force_full_scopes=plan.mode == "mixed", + ), + ) + + async def run_cycle(self, cycle: CaptureCycle) -> CaptureCycle: + """Process the current frontier; every acknowledgement remains in the existing SQL store.""" + while True: + async with self.sessions() as db: + work = await self.service.next_scope_work(db, self.fence, cycle.version.cycle_id) + if work is None: + break + try: + await self.scan_work(cycle, work) + except _ChildScopeAccessLost as failure: + # Preserve the provider failure without withdrawing its enclosing scope. + raise failure.error from failure + async with self.sessions() as db: + current = await self.service.read_cycle(db, self.fence) + if current is None: + raise CycleConflict("Capture cycle disappeared before finalization") + return current + + async def scan_work( + self, cycle: CaptureCycle, work: ScopeWork, *, max_pages: int | None = None + ) -> None: + """Ordinary and interleaved work share exact-owner and withdrawal handling.""" + scope = ( + CompletedScope(record_type=work.record_type) + if work.parent is None + else self.source.child_scope(work.parent, work.record_type) + ) + if scope.record_type != work.record_type or scope.parent != ( + work.parent.identity if work.parent else None + ): + raise CycleConflict("Source returned a scope with the wrong exact owner") + try: + await self.scan( + cycle, + scope, + parent=work.parent, + parent_epoch=work.parent_visibility_epoch, + refresh_membership=bool(cycle.configuration.children_of(work.record_type)), + max_pages=max_pages, + ) + except ScopeAccessLost as error: + if work.parent is None: + raise + if work.parent_visibility_epoch is None: + raise CycleConflict("Scope owner epoch is missing") from error + await self.withdraw_parent( + work.parent, work.parent_visibility_epoch, error.removal_reason + ) + if isinstance(error, RequiredScopeAccessLost): + raise + + async def advance_children(self, cycle: CaptureCycle, scope: CompletedScope) -> None: + """Give freshly observed, exactly verified owners one child page of progress.""" + if scope.record_type not in cycle.configuration.exact_parent_validation: + return + async with self.sessions() as db: + work = await self.service.next_scope_work( + db, self.fence, cycle.version.cycle_id, within=scope + ) + if work is not None: + try: + await self.scan_work(cycle, work, max_pages=1) + except RequiredScopeAccessLost as error: + raise _ChildScopeAccessLost(error) from error + + async def scan( + self, + cycle: CaptureCycle, + scope: CompletedScope, + *, + refresh_membership: bool = False, + parent: SourceRecord | None = None, + parent_epoch: int | None = None, + max_pages: int | None = None, + ) -> None: + """Only a provider invalid-cursor response permits one explicit sweep restart.""" + admission = await self.begin_scope(cycle, scope, parent, parent_epoch) + if admission.state is None: + return + state = admission.state + parent = admission.parent + restart_key = (cycle.version.cycle_id, scope) + pages = 0 + while state.phase == "collecting": + await self.check_limits() + try: + page = await self.source.capture_page( + scope, state.continuation, files=self.files, parent=parent + ) + except (InvalidScanContinuation, InvalidScopeCheckpoint) as error: + if restart_key in self._restarted_scopes: + raise + self._restarted_scopes.add(restart_key) + state = await self.restart_scope(cycle, scope, state, parent, error) + continue + # Never split a page or retry an uncertain commit with this old version. + async with self.sessions() as db: + result = await self.service.commit_scan_page( + db, + CommitScanPage( + fence=self.fence, + scope=scope, + cycle_id=state.cycle_id, + expected=state.version, + records=tuple(self._parented(r, scope) for r in page.records), + discovered_records=page.discovered_records, + child_scope_observations=page.child_scope_observations, + continuation=page.continuation, + final=page.final, + provider_checkpoint=page.provider_checkpoint, + ), + ) + state = result.state + await self.progress(result.capture, (*page.records, *page.discovered_records)) + pages += 1 + await self.advance_children(cycle, scope) + if max_pages is not None and pages >= max_pages and state.phase == "collecting": + return + if state.phase == "reconciling": + if state.completion_policy == "discovery_with_validation" or ( + state.scope.record_type in cycle.configuration.known_object_validation + ): + state = await self.refresh_known(state) + elif state.completion_policy == "exhaustive" and refresh_membership: + await self.confirm_omissions(state) + await self.reconcile(state) + + async def begin_scope( + self, + cycle: CaptureCycle, + scope: CompletedScope, + parent: SourceRecord | None, + parent_epoch: int | None, + ) -> ScanAdmission: + """Select a plan outside SQL, then attest prior version and owner under the fence.""" + async with self.sessions() as db: + previous = await self.service.read_scan(db, self.fence, scope) + restart = bool( + previous + and previous.cycle_id == cycle.version.cycle_id + and ( + ( + cycle.configuration.fresh_inventory(scope.record_type) + and previous.membership_attempt_id != self.fence.attempt_id + ) + or previous.parent_visibility_epoch != parent_epoch + ) + ) + scoped = isinstance(self.source, ScopedPageSource) + if cycle.mode == "mixed" and cycle.configuration.exact_parent_validation: + raise CycleConflict("Mixed planning does not support exact parent validation") + if cycle.mode == "mixed" and not scoped: + raise CycleConflict("Mixed capture requires source scope planning") + if scoped and cycle.force_full_scopes and previous and previous.mode == "changes": + restart = True + plan = None + if scoped: + if previous and previous.cycle_id == cycle.version.cycle_id and not restart: + if previous.execution is None: + raise CycleConflict("Mixed scope lost its persisted plan") + plan = previous.execution.plan + else: + plan = await self.source.prepare_scope( + scope, + cycle, + previous, + parent=parent, + force_full=cycle.force_full_scopes + or bool(previous and previous.parent_visibility_epoch != parent_epoch), + ) + observation = None + if parent and parent.identity.record_type in cycle.configuration.exact_parent_validation: + verified = bool( + previous + and previous.cycle_id == cycle.version.cycle_id + and previous.parent_verified_attempt_id == self.fence.attempt_id + and previous.parent_verified_revision == parent.revision + and previous.parent_visibility_epoch == parent_epoch + ) + if not verified: + if not isinstance(self.source, KnownObjectSource): + raise CycleConflict("Source requires exact owner refresh support") + await self.check_limits() + observation = await self.source.refresh_known(parent, files=self.files) + initial = self.scope_initial(scope, cycle, plan) + async with self.sessions() as db: + admission = await self.service.admit_scan( + db, + BeginScan( + fence=self.fence, + scope=scope, + cycle_id=cycle.version.cycle_id, + fingerprint=cycle.configuration.fingerprint, + expected=previous.version if previous else None, + restart=restart, + continuation=initial, + plan=plan, + expected_parent_epoch=parent_epoch, + expected_parent_revision=parent.revision if parent else None, + exact_parent_observation=observation, + ), + ) + + await self.progress(admission.capture, (observation,) if observation else ()) + return admission + + async def restart_scope( + self, + cycle: CaptureCycle, + scope: CompletedScope, + state: ScanState, + parent: SourceRecord | None, + error: Exception, + ) -> ScanState: + """Native cursor expiry can reset one sweep; native sync expiry forces full scope mode.""" + plan = state.execution.plan if state.execution else None + if isinstance(error, InvalidScopeCheckpoint): + if not isinstance(self.source, ScopedPageSource): + raise error + plan = await self.source.prepare_scope( + scope, cycle, state, parent=parent, force_full=True + ) + if plan.mode != "full": + raise CycleConflict("Invalid scope checkpoint requires a full scope restart") + initial = self.scope_initial(scope, cycle, plan) + async with self.sessions() as db: + return await self.service.begin_scan( + db, + BeginScan( + fence=self.fence, + scope=scope, + cycle_id=state.cycle_id, + fingerprint=state.fingerprint, + expected=state.version, + restart=True, + continuation=initial, + plan=plan, + expected_parent_epoch=state.parent_visibility_epoch, + expected_parent_revision=parent.revision if parent else None, + ), + ) + + def scope_initial( + self, scope: CompletedScope, cycle: CaptureCycle, plan: ScopePlan | None + ) -> ScanContinuation: + """Sources receive exactly the plan that will be committed with the new sweep.""" + if isinstance(self.source, ScopedPageSource): + if plan is None: + raise CycleConflict("Mixed source requires a scope plan") + return self.source.initial_scope_continuation(scope, cycle, plan) + return self.initial_continuation(cycle) + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """Sources without a native changes contract retain the empty initial cursor.""" + return ( + self.source.initial_continuation(cycle) + if isinstance(self.source, CheckpointedPageSource) + else ScanContinuation() + ) + + @staticmethod + def _parented(record: CaptureRecord, scope: CompletedScope) -> CaptureRecord: + """Legacy flat adapters may omit the parent; never overwrite a conflicting declaration.""" + if record.parent is not None and record.parent != scope.parent: + raise CycleConflict("Captured record declares a different scope parent") + return record.model_copy(update={"parent": scope.parent}) + + async def refresh_known(self, state: ScanState) -> ScanState: + """SQL sightings are the durable frontier; uncertain commits end this attempt.""" + if not isinstance(self.source, KnownObjectSource): + raise CycleConflict("Source does not implement exact known-object validation") + while True: + async with self.sessions() as db: + records = await self.service.scan_missing(db, self.fence, state) + if not records: + return state + for record in records: + await self.check_limits() + observation = await self.source.refresh_known(record, files=self.files) + async with self.sessions() as db: + result = await self.service.commit_omission( + db, + CommitOmission( + fence=self.fence, + state=state, + expected_record=record, + observation=observation, + ), + ) + state = result.state + await self.progress(result.capture, (observation,)) + + async def confirm_omissions(self, state: ScanState) -> None: + """Accessible omissions or provider errors fail before any absence removal.""" + after: UUID | None = None + while True: + async with self.sessions() as db: + records = await self.service.scan_missing(db, self.fence, state, after=after) + if not records: + return + for record in records: + await self.check_limits() + await self.source.confirm_absent(record) + after = records[-1].id + + async def reconcile(self, state: ScanState) -> None: + """Each bounded removal batch resumes from its committed scan version.""" + while state.phase == "reconciling": + await self.check_limits() + async with self.sessions() as db: + result = await self.service.reconcile_scan( + db, + ReconcileScan( + fence=self.fence, + scope=state.scope, + cycle_id=state.cycle_id, + expected=state.version, + observed_at=datetime.now(timezone.utc), + ), + ) + state = result.state + await self.progress(result.capture, ()) + + async def withdraw_parent( + self, parent: SourceRecord, expected_epoch: int, removal_reason: ScopeRemovalReason + ) -> None: + """Withdraw exact owner first; recursive visibility closes before bounded cleanup.""" + async with self.sessions() as db: + result = await self.service.withdraw_scan_parent( + db, + self.fence, + parent, + expected_epoch=expected_epoch, + removal_reason=removal_reason, + ) + await self.progress(result, ()) + while True: + await self.check_limits() + async with self.sessions() as db: + result = await self.service.reconcile_parents(db, self.fence) + await self.progress(result.capture, ()) + if not result.has_more: + return diff --git a/backend/airweave/domains/sync_pipeline/capture_attempt.py b/backend/airweave/domains/sync_pipeline/capture_attempt.py new file mode 100644 index 0000000000..acd8d0c8d6 --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/capture_attempt.py @@ -0,0 +1,28 @@ +"""Source activity attempt identity, including explicit non-Temporal invocation.""" + +from uuid import NAMESPACE_URL, UUID, uuid5 + +from pydantic import BaseModel, ConfigDict, Field +from temporalio import activity + + +class CaptureAttempt(BaseModel): + """Attempt number is supplied by the execution owner, never invented on restart.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: UUID + number: int = Field(ge=1) + + +def resolve_capture_attempt(explicit: CaptureAttempt | None = None) -> CaptureAttempt: + """Use real Temporal metadata or require a deliberate local invocation identity.""" + if explicit is not None: + return explicit + if not activity.in_activity(): + raise ValueError("Canonical capture outside Temporal requires an explicit CaptureAttempt") + info = activity.info() + identity = ( + f"temporal:{info.workflow_namespace}:{info.workflow_id}:" + f"{info.workflow_run_id}:{info.activity_id}:{info.attempt}" + ) + return CaptureAttempt(id=uuid5(NAMESPACE_URL, identity), number=info.attempt) diff --git a/backend/airweave/domains/sync_pipeline/contexts/runtime.py b/backend/airweave/domains/sync_pipeline/contexts/runtime.py index fc3ba60199..383086ba62 100644 --- a/backend/airweave/domains/sync_pipeline/contexts/runtime.py +++ b/backend/airweave/domains/sync_pipeline/contexts/runtime.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, List, Optional if TYPE_CHECKING: + from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline from airweave.domains.sync_pipeline.pipeline.entity_tracker import EntityTracker from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.destinations._base import BaseDestination @@ -28,5 +29,6 @@ class SyncRuntime: source: "BaseSource" entity_tracker: "EntityTracker" + canonical_capture: Optional["CanonicalCapturePipeline"] = None cursor: Optional["SyncCursor"] = None destinations: List["BaseDestination"] = field(default_factory=list) diff --git a/backend/airweave/domains/sync_pipeline/entity/pipeline.py b/backend/airweave/domains/sync_pipeline/entity/pipeline.py index 5198bbdbe2..5d01bc2ad2 100644 --- a/backend/airweave/domains/sync_pipeline/entity/pipeline.py +++ b/backend/airweave/domains/sync_pipeline/entity/pipeline.py @@ -13,10 +13,9 @@ import time from collections import defaultdict -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Dict, List from airweave.core.events.sync import EntityBatchProcessedEvent, TypeActionCounts -from airweave.core.shared_models import AirweaveFieldFlag from airweave.domains.entities.protocols import EntityRepositoryProtocol from airweave.domains.sync_pipeline.contexts import SyncContext from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime @@ -25,6 +24,7 @@ from airweave.domains.sync_pipeline.pipeline.cleanup_service import cleanup_service from airweave.domains.sync_pipeline.pipeline.entity_tracker import EntityTracker from airweave.domains.sync_pipeline.pipeline.hash_computer import hash_computer +from airweave.domains.sync_pipeline.processors.entity_fields import populate_base_fields from airweave.domains.sync_pipeline.protocols import ( EntityActionDispatcherProtocol, EntityActionResolverProtocol, @@ -333,39 +333,9 @@ async def _cleanup_temp_files_for_batch( # Helper Methods # ------------------------------------------------------------------------- - def _get_flagged_field_value(self, entity: BaseEntity, flag_name: str) -> Any: - """Extract value of field marked with specified flag.""" - flag_key = flag_name.value if hasattr(flag_name, "value") else flag_name - - for field_name, field_info in entity.model_fields.items(): - json_extra = field_info.json_schema_extra - if json_extra and isinstance(json_extra, dict): - if json_extra.get(flag_key): - return getattr(entity, field_name, None) - - return None - def _populate_base_entity_fields_from_flags(self, entity: BaseEntity) -> None: - """Populate BaseEntity fields from flagged fields.""" - if not entity.entity_id: - flagged_value = self._get_flagged_field_value(entity, AirweaveFieldFlag.IS_ENTITY_ID) - if flagged_value: - entity.entity_id = str(flagged_value) - - if not entity.name: - flagged_value = self._get_flagged_field_value(entity, AirweaveFieldFlag.IS_NAME) - if flagged_value: - entity.name = str(flagged_value) - - if not entity.created_at: - flagged_value = self._get_flagged_field_value(entity, AirweaveFieldFlag.IS_CREATED_AT) - if flagged_value is not None: - entity.created_at = flagged_value - - if not entity.updated_at: - flagged_value = self._get_flagged_field_value(entity, AirweaveFieldFlag.IS_UPDATED_AT) - if flagged_value is not None: - entity.updated_at = flagged_value + """Use shared flag normalization for legacy and canonical projection.""" + populate_base_fields(entity) async def _enrich_early_metadata( self, diff --git a/backend/airweave/domains/sync_pipeline/factory.py b/backend/airweave/domains/sync_pipeline/factory.py index cdf225b02f..692ce64ef3 100644 --- a/backend/airweave/domains/sync_pipeline/factory.py +++ b/backend/airweave/domains/sync_pipeline/factory.py @@ -15,6 +15,7 @@ from typing import List, Optional from uuid import UUID +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from airweave import crud, schemas @@ -22,6 +23,7 @@ from airweave.core.exceptions import NotFoundException from airweave.core.logging import ContextualLogger, LoggerConfigurator, logger from airweave.core.protocols.event_bus import EventBus +from airweave.db.session import get_db_context from airweave.domains.access_control.dispatcher import ACActionDispatcher from airweave.domains.access_control.membership_tracker import ACLMembershipTracker from airweave.domains.access_control.pipeline import AccessControlPipeline @@ -31,6 +33,10 @@ from airweave.domains.arf.protocols import ArfServiceProtocol from airweave.domains.browse_tree.protocols import NodeSelectionRepositoryProtocol from airweave.domains.browse_tree.types import NodeSelectionData +from airweave.domains.entities.canonical.page_source import CanonicalPageSource +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.source import CanonicalSource, ContainerScopedSource +from airweave.domains.entities.canonical.store import CanonicalRecordStore, StaleWriter from airweave.domains.entities.protocols import ( EntityCountRepositoryProtocol, EntityRepositoryProtocol, @@ -43,6 +49,8 @@ from airweave.domains.storage.protocols import StorageBackend from airweave.domains.sync_pipeline.builders import SyncContextBuilder from airweave.domains.sync_pipeline.builders.destinations import DestinationsContextBuilder +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt, resolve_capture_attempt from airweave.domains.sync_pipeline.config import SyncConfig from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime from airweave.domains.sync_pipeline.contexts.sync import SyncContext @@ -60,7 +68,11 @@ from airweave.domains.syncs.jobs.protocols import SyncJobStateMachineProtocol from airweave.domains.syncs.protocols import SyncStateMachineProtocol from airweave.domains.usage.protocols import UsageLedgerProtocol, UsageLimitCheckerProtocol +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity from airweave.models.source_connection import SourceConnection +from airweave.models.sync_cursor import SyncCursor as StoredSyncCursor +from airweave.platform.configs.config import OutlookMailConfig, StripeConfig from airweave.platform.sources._base import BaseSource from .entity.pipeline import EntityPipeline @@ -145,6 +157,7 @@ async def create_orchestrator( force_full_sync: bool = False, execution_config: Optional[SyncConfig] = None, access_token: Optional[str] = None, + capture_attempt: CaptureAttempt | None = None, ) -> SyncOrchestrator: """Create a dedicated orchestrator instance for a sync run.""" init_start = time.time() @@ -161,7 +174,11 @@ async def create_orchestrator( f"destinations={resolved_config.destinations.model_dump()}" ) + admission = CanonicalRecordStore() + generation = await admission.admit_job(db, ctx.organization.id, sync.id, sync_job.id) sc = await self._resolve_source_connection(db, sync, ctx) + # The session may already hold config from before a reconnect. + await db.refresh(sc) sc_id = sc.id # extract before _build_source can expire the ORM instance via OAuth refresh # 2. Build source, destinations, tracker @@ -184,14 +201,23 @@ async def create_orchestrator( execution_config=resolved_config, access_token=access_token, ) + if await admission.admit_job(db, ctx.organization.id, sync.id, sync_job.id) != generation: + raise StaleWriter("Source authorization changed during initialization") source_entry = self._source_registry.get(sc.short_name) - destinations = await self._build_destinations( - db=db, - sync=sync, - collection=collection, - ctx=ctx, - execution_config=resolved_config, - source_supports_acl=source_entry.supports_access_control, + canonical_source = source_result.source.capture_page_source + if canonical_source is None and isinstance(source_result.source, CanonicalSource): + canonical_source = source_result.source + destinations = ( + [] + if canonical_source is not None + else await self._build_destinations( + db=db, + sync=sync, + collection=collection, + ctx=ctx, + execution_config=resolved_config, + source_supports_acl=source_entry.supports_access_control, + ) ) entity_tracker = await self._build_entity_tracker( db=db, @@ -224,9 +250,28 @@ async def create_orchestrator( logger.debug(f"Context + runtime built in {time.time() - init_start:.2f}s") # 4. Wire pipelines - entity_pipeline = self._build_entity_pipeline( - sync_context, runtime, destinations, resolved_config - ) + if canonical_source is not None: + runtime.canonical_capture = CanonicalCapturePipeline( + service=CanonicalCaptureService(CanonicalRecordStore()), + sessions=get_db_context, + event_bus=self._event_bus, + record_types=canonical_source.canonical_record_types, + attempt=resolve_capture_attempt(capture_attempt), + files=source_result.files, + page_source=( + canonical_source if isinstance(canonical_source, CanonicalPageSource) else None + ), + container_parents=( + canonical_source.canonical_container_parents + if isinstance(canonical_source, ContainerScopedSource) + else None + ), + ) + entity_pipeline = runtime.canonical_capture + else: + entity_pipeline = self._build_entity_pipeline( + sync_context, runtime, destinations, resolved_config + ) access_control_pipeline = self._build_access_control_pipeline(sync_context) stream = self._build_stream(runtime, source_result, sync_context) @@ -314,14 +359,26 @@ def _build_stream( runtime: SyncRuntime, source_result: SourceBuildResult, sync_context: SyncContext, - ) -> AsyncSourceStream: - """Build the async source stream from the source generator.""" - return AsyncSourceStream( - source_generator=runtime.source.generate_entities( + ) -> AsyncSourceStream | None: + """Page sources are driven sequentially; legacy generators retain their stream.""" + if runtime.source.capture_page_source is not None: + if source_result.node_selections: + raise ValueError("Whole-scope page capture does not support selected nodes") + return None + if isinstance(runtime.source, CanonicalSource): + generator = runtime.source.generate_observations( cursor=runtime.cursor, files=source_result.files, node_selections=source_result.node_selections, - ), + ) + else: + generator = runtime.source.generate_entities( + cursor=runtime.cursor, + files=source_result.files, + node_selections=source_result.node_selections, + ) + return AsyncSourceStream( + source_generator=generator, queue_size=10000, logger=sync_context.logger, ) @@ -343,7 +400,24 @@ async def _build_source( access_token: Optional[str] = None, ) -> SourceBuildResult: """Build source instance, cursor, file service, and node selections.""" + composed_capture = False + if source_connection.short_name == "outlook_mail": + composed_capture = OutlookMailConfig.model_validate( + source_connection.config_fields or {} + ).capture_originals + elif source_connection.short_name == "stripe": + composed_capture = ( + StripeConfig.model_validate(source_connection.config_fields or {}).original_capture + is not None + ) + if source_connection.short_name in {"outlook_mail", "stripe"}: + await self._validate_composed_capture_mode(db, sync.id, ctx, composed_capture) if execution_config and execution_config.behavior.replay_from_arf: + source_class = self._source_registry.get(source_connection.short_name).source_class_ref + if composed_capture or isinstance(source_class, (CanonicalSource, CanonicalPageSource)): + raise ValueError( + "Canonical records must be reindexed from Postgres, not mutable ARF" + ) return await self._build_arf_replay_source(db=db, sync=sync, ctx=ctx, logger=logger) self._validate_not_completed_snapshot(source_connection) @@ -357,7 +431,9 @@ async def _build_source( access_token=access_token, ) - files = FileService(sync_job_id=sync_job.id, storage_backend=self._storage_backend) + files = FileService( + sync_job_id=sync_job.id, sync_id=sync.id, storage_backend=self._storage_backend + ) cursor = await self._create_cursor( db=db, @@ -377,6 +453,46 @@ async def _build_source( source=source, cursor=cursor, files=files, node_selections=node_selections ) + async def _validate_composed_capture_mode( + self, db: AsyncSession, sync_id: UUID, ctx: BaseContext, capture_originals: bool + ) -> None: + """Never reinterpret persisted IDs or cursors when selecting an original capture adapter.""" + scope = (Entity.sync_id == sync_id, Entity.organization_id == ctx.organization.id) + legacy, canonical, scanned = ( + await db.execute( + select( + select(Entity.id).where(*scope, Entity.record_revision == 0).exists(), + select(Entity.id).where(*scope, Entity.record_revision > 0).exists(), + select(CaptureScan.id) + .where( + CaptureScan.sync_id == sync_id, + CaptureScan.organization_id == ctx.organization.id, + ) + .exists(), + ) + ) + ).one() + cursor = await db.scalar( + select(StoredSyncCursor.cursor_data).where( + StoredSyncCursor.sync_id == sync_id, + StoredSyncCursor.organization_id == ctx.organization.id, + ) + ) + cursor = cursor or {} + canonical_keys = {"canonical_cycle", "canonical_checkpoint"} + has_canonical_cursor = bool(canonical_keys.intersection(cursor)) + has_legacy_cursor = any( + value is not None and value != {} and value != [] + for key, value in cursor.items() + if key not in canonical_keys + ) + if (capture_originals and (legacy or has_legacy_cursor)) or ( + not capture_originals and (canonical or scanned or has_canonical_cursor) + ): + raise ValueError( + "Original capture mode differs from retained state; create a new source" + ) + async def _build_arf_replay_source( self, db: AsyncSession, diff --git a/backend/airweave/domains/sync_pipeline/file_types.py b/backend/airweave/domains/sync_pipeline/file_types.py index 1f3f44af70..43b0fbb0e1 100644 --- a/backend/airweave/domains/sync_pipeline/file_types.py +++ b/backend/airweave/domains/sync_pipeline/file_types.py @@ -3,7 +3,7 @@ Single source of truth for supported file types across downloader and pipeline. """ -_DOCUMENT_EXTENSIONS = {".pdf", ".doc", ".docx", ".pptx"} +_DOCUMENT_EXTENSIONS = {".pdf", ".doc", ".docx", ".pptx", ".xlsx"} _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"} _HTML_EXTENSIONS = {".html", ".htm"} _TEXT_EXTENSIONS = {".txt", ".json", ".xml", ".md", ".yaml", ".yml", ".toml"} diff --git a/backend/airweave/domains/sync_pipeline/orchestrator.py b/backend/airweave/domains/sync_pipeline/orchestrator.py index d8e30cc928..888b4064c4 100644 --- a/backend/airweave/domains/sync_pipeline/orchestrator.py +++ b/backend/airweave/domains/sync_pipeline/orchestrator.py @@ -19,6 +19,7 @@ from airweave.db.session import get_db_context from airweave.domains.access_control.pipeline import AccessControlPipeline from airweave.domains.sources.exceptions.classifier import classify_error +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline from airweave.domains.sync_pipeline.contexts import SyncContext from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime from airweave.domains.sync_pipeline.entity.pipeline import EntityPipeline @@ -53,9 +54,9 @@ class SyncOrchestrator: def __init__( self, - entity_pipeline: EntityPipeline, + entity_pipeline: EntityPipeline | CanonicalCapturePipeline, worker_pool: AsyncWorkerPool, - stream: AsyncSourceStream, + stream: AsyncSourceStream | None, sync_context: SyncContext, runtime: SyncRuntime, access_control_pipeline: AccessControlPipeline, @@ -196,17 +197,29 @@ async def _start_sync(self) -> None: """Initialize sync job and start all components.""" self.sync_context.logger.info("Starting sync job") - await self.stream.start() - await self._state_machine.transition( sync_job_id=self.sync_context.sync_job.id, target=SyncJobStatus.RUNNING, ctx=self.sync_context, lifecycle_data=self._lifecycle_data, ) + if self.runtime.canonical_capture is not None: + await self.runtime.canonical_capture.start(self.sync_context) + if self.stream is not None: + await self.stream.start() async def _process_entities(self) -> None: # noqa: C901 """Process entities using micro-batching with bounded inner concurrency.""" + if ( + self.runtime.canonical_capture is not None + and self.runtime.canonical_capture.page_source is not None + ): + await self.runtime.canonical_capture.run_scans( + self.sync_context, self.runtime, self._check_capture_limits + ) + return + if self.stream is None: + raise SyncFailureError("Observation source has no stream") source_name = self.runtime.source.source_name self.sync_context.logger.info( f"Starting pull-based processing from source {source_name} " @@ -297,6 +310,14 @@ async def _process_entities(self) -> None: # noqa: C901 if stream_error: raise stream_error + async def _check_capture_limits(self) -> None: + """Keep the existing usage guard before each durable provider page.""" + if not self.sync_context.execution_config.behavior.skip_guardrails: + async with get_db_context() as db: + await self._usage_checker.is_allowed( + db, self.sync_context.organization.id, ActionType.ENTITIES + ) + async def _submit_batch_and_trim( self, batch: list, @@ -306,6 +327,15 @@ async def _submit_batch_and_trim( if not batch: return pending_tasks + if self.runtime.canonical_capture is not None: + # Preserve per-identity observation order. Source I/O remains concurrent. + await self.runtime.canonical_capture.process( + entities=list(batch), + sync_context=self.sync_context, + runtime=self.runtime, + ) + return pending_tasks + task = await self.worker_pool.submit( self.entity_pipeline.process, entities=list(batch), @@ -449,6 +479,12 @@ async def _finalize_stream_and_tasks( async def _cleanup_orphaned_entities_if_needed(self) -> None: """Cleanup orphaned entities based on sync type.""" + if self.runtime.canonical_capture is not None: + await self.runtime.canonical_capture.cleanup_orphaned_entities( + self.sync_context, + self.runtime, + ) + return cursor = self.runtime.cursor is_incremental = cursor is not None and cursor.loaded_from_db @@ -508,6 +544,8 @@ async def _process_access_control_memberships(self) -> None: f"ACL sync error: {get_error_message(e)}", exc_info=True, ) + if self.runtime.canonical_capture is not None: + raise await self._publish_acl_heartbeat() # Don't fail the entire sync for ACL errors @@ -630,6 +668,9 @@ async def _update_snapshot_short_name(self) -> None: async def _save_cursor_data(self) -> None: """Save cursor data to database if it exists.""" + if self.runtime.canonical_capture is not None: + await self.runtime.canonical_capture.save_checkpoint(self.sync_context, self.runtime) + return if self.runtime.cursor is None: return @@ -664,6 +705,8 @@ async def _save_cursor_data(self) -> None: f"Failed to save cursor data for sync {self.sync_context.sync.id}: {e}", exc_info=True, ) + # A run without its durable continuation boundary is not complete. + raise async def _handle_sync_failure( self, error: Exception @@ -755,7 +798,8 @@ async def _handle_cancellation(self) -> None: await self.worker_pool.cancel_all() # Cancel stream to stop producer - await self.stream.cancel() + if self.stream is not None: + await self.stream.cancel() # Transition through CANCELLING → CANCELLED. # RUNNING → CANCELLING is required by the state machine. diff --git a/backend/airweave/domains/sync_pipeline/pipeline/text_builder.py b/backend/airweave/domains/sync_pipeline/pipeline/text_builder.py index 75a6d46901..dc801b0bea 100644 --- a/backend/airweave/domains/sync_pipeline/pipeline/text_builder.py +++ b/backend/airweave/domains/sync_pipeline/pipeline/text_builder.py @@ -9,11 +9,18 @@ from airweave.domains.converters.protocols import ConverterRegistryProtocol from airweave.domains.sync_pipeline.exceptions import EntityProcessingError, SyncFailureError from airweave.domains.sync_pipeline.file_types import SUPPORTED_FILE_EXTENSIONS +from airweave.domains.sync_pipeline.pipeline.text_models import ( + BuiltText, + BuiltTextBatch, + NativeTextBody, +) from airweave.platform.entities._base import BaseEntity, CodeFileEntity, FileEntity, WebEntity if TYPE_CHECKING: - from airweave.domains.sync_pipeline.contexts import SyncContext - from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime + from airweave.domains.sync_pipeline.processors.context import ( + ProcessingContext, + ProcessingRuntime, + ) class TextualRepresentationBuilder: @@ -43,8 +50,8 @@ def __init__(self, converter_registry: Optional[ConverterRegistryProtocol] = Non async def build_for_batch( self, entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", ) -> List[BaseEntity]: """Build textual_representation for all entities in batch. @@ -60,47 +67,109 @@ async def build_for_batch( Modifies entities in-place, setting textual_representation. Failed entities are removed and counted as skipped. """ + return (await self.build_with_text(entities, sync_context, runtime)).entities + + async def build_with_text( + self, + entities: List[BaseEntity], + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", + *, + native_bodies: dict[str, NativeTextBody] | None = None, + ) -> BuiltTextBatch: + """Retain exact converted text and content boundaries before chunking clears them.""" + native_bodies = native_bodies or {} + if set(native_bodies) - {entity.entity_id for entity in entities}: + raise EntityProcessingError("Native body does not belong to this text batch") + if any( + entity.entity_id in native_bodies and isinstance(entity, (FileEntity, WebEntity)) + for entity in entities + ): + raise EntityProcessingError("Native body cannot override converted content") + content_starts: dict[str, int] = {} source_name = sync_context.source_short_name # Step 1: Build metadata section for all entities - await self._build_metadata_for_all(entities, source_name) + await self._build_metadata_for_all(entities, source_name, native_bodies) + for entity in entities: + body = native_bodies.get(entity.entity_id) + if body is not None: + prefix = f"{entity.textual_representation}\n\n# Content\n\n" + content_starts[entity.entity_id] = len(prefix) + entity.textual_representation = prefix + body.text # Step 2: Partition entities by converter converter_groups, failed_entities = self._partition_by_converter(entities, sync_context) # Step 3: Convert each partition - additional_failures = await self._convert_partitions(converter_groups, sync_context) + additional_failures = await self._convert_partitions( + converter_groups, sync_context, content_starts + ) failed_entities.extend(additional_failures) # Step 4: Handle failures await self._handle_conversion_failures(entities, failed_entities, sync_context, runtime) - return entities + return BuiltTextBatch( + entities=entities, + representations=tuple( + BuiltText( + entity_id=entity.entity_id, + text=entity.textual_representation or "", + content_start=content_starts.get(entity.entity_id), + kind=( + "native_text" + if entity.entity_id in native_bodies + else "extracted_text" + if entity.entity_id in content_starts + else "generated_text" + ), + ) + for entity in entities + ), + ) # ------------------------------------------------------------------------------------ # Metadata Building # ------------------------------------------------------------------------------------ - async def _build_metadata_for_all(self, entities: List[BaseEntity], source_name: str) -> None: + async def _build_metadata_for_all( + self, + entities: List[BaseEntity], + source_name: str, + native_bodies: dict[str, NativeTextBody], + ) -> None: """Build metadata section for all entities. Args: entities: Entities to build metadata for source_name: Name of the source connector + native_bodies: Explicit source bodies whose fields leave the metadata section Note: CodeFileEntity is exempt because code is self-documenting. """ async def build_metadata(entity: BaseEntity): - metadata = self.build_metadata_section(entity, source_name) + body = native_bodies.get(entity.entity_id) + metadata = self.build_metadata_section( + entity, + source_name, + exclude_fields=body.metadata_fields if body else (), + ) if not metadata and not isinstance(entity, CodeFileEntity): raise EntityProcessingError(f"Empty metadata for {entity.entity_id}") entity.textual_representation = metadata await asyncio.gather(*[build_metadata(e) for e in entities]) - def build_metadata_section(self, entity: BaseEntity, source_name: str) -> str: + def build_metadata_section( + self, + entity: BaseEntity, + source_name: str, + *, + exclude_fields: tuple[str, ...] = (), + ) -> str: """Build metadata section for any entity type. This method is public to allow federated search sources to build @@ -111,6 +180,7 @@ def build_metadata_section(self, entity: BaseEntity, source_name: str) -> str: Args: entity: Entity to build metadata for source_name: Name of the source (e.g., "slack", "github") + exclude_fields: Explicit fields represented in the separate native body Returns: Markdown formatted metadata section @@ -134,7 +204,11 @@ def build_metadata_section(self, entity: BaseEntity, source_name: str) -> str: lines.append(f"**Path**: {path_str}") # Add embeddable fields - embeddable_fields = self._extract_embeddable_fields(entity) + embeddable_fields = { + key: value + for key, value in self._extract_embeddable_fields(entity).items() + if key not in exclude_fields + } if embeddable_fields: lines.append("") lines.append(self._format_embeddable_fields_as_markdown(embeddable_fields)) @@ -247,7 +321,7 @@ def _format_value(self, value: Any) -> str: def _partition_by_converter( self, entities: List[BaseEntity], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> Tuple[Dict[Any, List[Tuple[BaseEntity, str]]], List[BaseEntity]]: """Partition entities by their converter type. @@ -313,13 +387,15 @@ def _get_converter_and_key(self, entity: BaseEntity) -> Tuple[Any, Optional[str] async def _convert_partitions( self, converter_groups: Dict[Any, List[Tuple[BaseEntity, str]]], - sync_context: "SyncContext", + sync_context: "ProcessingContext", + content_starts: dict[str, int], ) -> List[BaseEntity]: """Execute batch conversion for each converter group. Args: converter_groups: Dict mapping converter to (entity, key) tuples sync_context: Sync context for logging + content_starts: Per-entity converter-content offsets to populate Returns: List of entities that failed conversion @@ -331,7 +407,9 @@ async def _convert_partitions( for i in range(0, len(entity_key_pairs), batch_size): sub_batch = entity_key_pairs[i : i + batch_size] - failures = await self._convert_sub_batch(converter, sub_batch, sync_context) + failures = await self._convert_sub_batch( + converter, sub_batch, sync_context, content_starts + ) failed_entities.extend(failures) return failed_entities @@ -340,7 +418,8 @@ async def _convert_sub_batch( self, converter: Any, sub_batch: List[Tuple[BaseEntity, str]], - sync_context: "SyncContext", + sync_context: "ProcessingContext", + content_starts: dict[str, int], ) -> List[BaseEntity]: """Convert a sub-batch of entities using the given converter. @@ -348,6 +427,7 @@ async def _convert_sub_batch( converter: Converter module to use sub_batch: List of (entity, key) tuples sync_context: Sync context for logging + content_starts: Per-entity converter-content offsets to populate Returns: List of entities that failed conversion @@ -372,7 +452,9 @@ async def _convert_sub_batch( failed_entities.append(entity) continue - entity.textual_representation += f"\n\n# Content\n\n{text_content}" + prefix = f"{entity.textual_representation}\n\n# Content\n\n" + content_starts[entity.entity_id] = len(prefix) + entity.textual_representation = prefix + text_content except SyncFailureError: # Infrastructure failure - propagate to fail entire sync @@ -403,8 +485,8 @@ async def _handle_conversion_failures( self, entities: List[BaseEntity], failed_entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", ) -> None: """Remove failed entities and update progress. diff --git a/backend/airweave/domains/sync_pipeline/pipeline/text_models.py b/backend/airweave/domains/sync_pipeline/pipeline/text_models.py new file mode 100644 index 0000000000..3f2e0d3c5e --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/pipeline/text_models.py @@ -0,0 +1,33 @@ +"""One conversion result, shared by indexing and optional retained representation reads.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from airweave.platform.entities._base import BaseEntity + + +class NativeTextBody(BaseModel): + """Source-selected body; an empty string is present content, not missing content.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + text: str + metadata_fields: tuple[str, ...] = () + + +class BuiltText(BaseModel): + """Content starts at a known construction boundary, never a parsed delimiter.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + entity_id: str + text: str + content_start: int | None = Field(default=None, ge=0) + kind: Literal["native_text", "extracted_text", "generated_text"] = "generated_text" + + +class BuiltTextBatch(BaseModel): + """Entities retain the same strings that were passed to the chunker.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + entities: list[BaseEntity] + representations: tuple[BuiltText, ...] diff --git a/backend/airweave/domains/sync_pipeline/processors/chunk_embed.py b/backend/airweave/domains/sync_pipeline/processors/chunk_embed.py index 237d929e24..af0f96ac56 100644 --- a/backend/airweave/domains/sync_pipeline/processors/chunk_embed.py +++ b/backend/airweave/domains/sync_pipeline/processors/chunk_embed.py @@ -19,12 +19,15 @@ from airweave.domains.embedders.protocols import DenseEmbedderProtocol, SparseEmbedderProtocol from airweave.domains.sync_pipeline.exceptions import EntityProcessingError, SyncFailureError from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder +from airweave.domains.sync_pipeline.pipeline.text_models import BuiltTextBatch, NativeTextBody from airweave.domains.sync_pipeline.processors.utils import filter_empty_representations from airweave.platform.entities._base import BaseEntity, CodeFileEntity if TYPE_CHECKING: - from airweave.domains.sync_pipeline.contexts import SyncContext - from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime + from airweave.domains.sync_pipeline.processors.context import ( + ProcessingContext, + ProcessingRuntime, + ) class ChunkEmbedProcessor: @@ -37,27 +40,71 @@ def __init__( sparse_embedder: SparseEmbedderProtocol, ) -> None: """Initialize with converter registry and embedding providers.""" + self._converter_registry = converter_registry self._text_builder = TextualRepresentationBuilder(converter_registry) self._dense_embedder = dense_embedder self._sparse_embedder = sparse_embedder + def supports_file_extension(self, extension: str) -> bool: + """Whether this processor has a configured converter for these bytes.""" + return self._converter_registry.for_extension(extension) is not None + async def process( self, entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", + *, + strict: bool = False, ) -> List[BaseEntity]: """Process entities through full chunk+embed pipeline.""" if not entities: return [] + expected_ids = {entity.entity_id for entity in entities} + if strict and len(expected_ids) != len(entities): + raise EntityProcessingError("Projection mapper returned duplicate part identities") + # Step 1: Build textual representations processed = await self._text_builder.build_for_batch(entities, sync_context, runtime) + return await self.process_built_text( + processed, sync_context, runtime, strict=strict, expected_ids=expected_ids + ) + + async def build_text( + self, + entities: List[BaseEntity], + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", + *, + native_bodies: dict[str, NativeTextBody] | None = None, + ) -> BuiltTextBatch: + """Convert once, exposing the complete pre-chunk representation for retention.""" + return await self._text_builder.build_with_text( + entities, sync_context, runtime, native_bodies=native_bodies + ) + + async def process_built_text( + self, + processed: List[BaseEntity], + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", + *, + strict: bool = False, + expected_ids: set[str] | None = None, + ) -> List[BaseEntity]: + """Chunk already-built text without rerunning converters.""" + if expected_ids is None: + expected_ids = {entity.entity_id for entity in processed} + if strict and len(expected_ids) != len(processed): + raise EntityProcessingError("Projection conversion dropped required content") # Step 2: Filter empty representations processed = await filter_empty_representations( processed, sync_context, runtime, "ChunkEmbed" ) + if strict and {entity.entity_id for entity in processed} != expected_ids: + raise EntityProcessingError("Projection conversion dropped required content") if not processed: sync_context.logger.debug("[ChunkEmbedProcessor] No entities after text building") return [] @@ -65,15 +112,25 @@ async def process( # Step 3: Chunk entities chunk_entities = await self._chunk_entities(processed, sync_context, runtime) + if ( + strict + and {entity.airweave_system_metadata.original_entity_id for entity in chunk_entities} + != expected_ids + ): + raise EntityProcessingError("Projection chunking dropped required content") + expected_chunks = {entity.entity_id for entity in chunk_entities} + # Step 4: Release parent text (memory optimization) for entity in processed: entity.textual_representation = None # Step 5: Embed chunks (may remove entities that fail embedding) chunk_entities = await self._embed_entities(chunk_entities, sync_context) + if strict and {entity.entity_id for entity in chunk_entities} != expected_chunks: + raise EntityProcessingError("Projection embedding dropped required chunks") sync_context.logger.debug( - f"[ChunkEmbedProcessor] {len(entities)} entities -> {len(chunk_entities)} chunks" + f"[ChunkEmbedProcessor] {len(processed)} entities -> {len(chunk_entities)} chunks" ) return chunk_entities @@ -85,8 +142,8 @@ async def process( async def _chunk_entities( self, entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", ) -> List[BaseEntity]: """Route entities to appropriate chunker.""" code_entities = [e for e in entities if isinstance(e, CodeFileEntity)] @@ -107,8 +164,8 @@ async def _chunk_entities( async def _chunk_code_entities( self, entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", ) -> List[BaseEntity]: """Chunk code with AST-aware CodeChunker.""" from airweave.platform.chunkers.code import CodeChunker @@ -134,7 +191,7 @@ async def _chunk_code_entities( async def _chunk_textual_entities( self, entities: List[BaseEntity], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> List[BaseEntity]: """Chunk text with SemanticChunker.""" from airweave.platform.chunkers.semantic import SemanticChunker @@ -180,7 +237,7 @@ def _multiply_entities( self, entities: List[BaseEntity], chunk_lists: List[List[Dict[str, Any]]], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> List[BaseEntity]: """Create chunk entities from chunker output.""" chunk_entities: List[BaseEntity] = [] @@ -213,7 +270,7 @@ def _multiply_entities( async def _embed_entities( self, chunk_entities: List[BaseEntity], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> List[BaseEntity]: """Compute dense and sparse embeddings for all destinations. @@ -279,7 +336,7 @@ def _validate_dense_dimensions(self, dense_results: List[Any]) -> None: async def _dense_embed_with_fallback( self, chunk_entities: List[BaseEntity], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> Tuple[List[Any], List[BaseEntity]]: """Run dense embedding with fallback to individual embedding on failure. @@ -325,7 +382,7 @@ async def _dense_embed_with_fallback( async def _embed_individually( self, chunk_entities: List[BaseEntity], - sync_context: "SyncContext", + sync_context: "ProcessingContext", ) -> Tuple[List[Any], List[BaseEntity]]: """Embed entities one-by-one, skipping any that fail. diff --git a/backend/airweave/domains/sync_pipeline/processors/context.py b/backend/airweave/domains/sync_pipeline/processors/context.py new file mode 100644 index 0000000000..94ba7bc3a1 --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/processors/context.py @@ -0,0 +1,26 @@ +"""Minimal processing dependencies shared by capture-era and replay processing.""" + +from typing import Protocol + +from airweave.core.logging import ContextualLogger + + +class ProcessingContext(Protocol): + """Identity and diagnostics needed by text conversion/chunking.""" + + logger: ContextualLogger + source_short_name: str + + +class ProcessingTracker(Protocol): + """Existing processing skip accounting interface.""" + + async def record_skipped(self, count: int) -> None: + """Record that input could not be converted.""" + ... + + +class ProcessingRuntime(Protocol): + """Processing never requires a live source or its credentials.""" + + entity_tracker: ProcessingTracker diff --git a/backend/airweave/domains/sync_pipeline/processors/entity_fields.py b/backend/airweave/domains/sync_pipeline/processors/entity_fields.py new file mode 100644 index 0000000000..eb6d75322a --- /dev/null +++ b/backend/airweave/domains/sync_pipeline/processors/entity_fields.py @@ -0,0 +1,26 @@ +"""Normalize declared entity display fields before either indexing pipeline.""" + +from airweave.core.shared_models import AirweaveFieldFlag +from airweave.platform.entities._base import BaseEntity + + +def populate_base_fields(entity: BaseEntity) -> None: + """Copy explicitly flagged source fields without serializing native capture JSON.""" + flags = { + "entity_id": AirweaveFieldFlag.IS_ENTITY_ID, + "name": AirweaveFieldFlag.IS_NAME, + "created_at": AirweaveFieldFlag.IS_CREATED_AT, + "updated_at": AirweaveFieldFlag.IS_UPDATED_AT, + } + for target, flag in flags.items(): + if getattr(entity, target): + continue + for name, field in type(entity).model_fields.items(): + metadata = field.json_schema_extra + if isinstance(metadata, dict) and metadata.get(flag.value): + value = getattr(entity, name) + if value is not None: + setattr( + entity, target, str(value) if target in ("entity_id", "name") else value + ) + break diff --git a/backend/airweave/domains/sync_pipeline/processors/utils.py b/backend/airweave/domains/sync_pipeline/processors/utils.py index ddef0c3643..4dde762ddd 100644 --- a/backend/airweave/domains/sync_pipeline/processors/utils.py +++ b/backend/airweave/domains/sync_pipeline/processors/utils.py @@ -5,14 +5,16 @@ from airweave.platform.entities._base import BaseEntity if TYPE_CHECKING: - from airweave.domains.sync_pipeline.contexts import SyncContext - from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime + from airweave.domains.sync_pipeline.processors.context import ( + ProcessingContext, + ProcessingRuntime, + ) async def filter_empty_representations( entities: List[BaseEntity], - sync_context: "SyncContext", - runtime: "SyncRuntime", + sync_context: "ProcessingContext", + runtime: "ProcessingRuntime", processor_name: str = "Processor", ) -> List[BaseEntity]: """Filter entities with empty textual_representation. diff --git a/backend/airweave/domains/sync_pipeline/protocols.py b/backend/airweave/domains/sync_pipeline/protocols.py index 6969152114..17b65947c5 100644 --- a/backend/airweave/domains/sync_pipeline/protocols.py +++ b/backend/airweave/domains/sync_pipeline/protocols.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from airweave.core.context import BaseContext + from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt from airweave.domains.sync_pipeline.config import SyncConfig from airweave.domains.sync_pipeline.contexts import SyncContext from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime @@ -105,6 +106,7 @@ async def create_orchestrator( force_full_sync: bool = False, execution_config: Optional[SyncConfig] = None, access_token: Optional[str] = None, + capture_attempt: CaptureAttempt | None = None, ) -> SyncOrchestrator: """Create and return a fully-wired SyncOrchestrator.""" ... diff --git a/backend/airweave/domains/sync_pipeline/stream.py b/backend/airweave/domains/sync_pipeline/stream.py index 5c9f7e9bd1..6d32874456 100644 --- a/backend/airweave/domains/sync_pipeline/stream.py +++ b/backend/airweave/domains/sync_pipeline/stream.py @@ -5,10 +5,9 @@ from typing import AsyncGenerator, Generic, Optional, TypeVar from airweave.core.logging import ContextualLogger -from airweave.platform.entities._base import BaseEntity from airweave.platform.utils.error_utils import get_error_message -T = TypeVar("T", bound=BaseEntity) +T = TypeVar("T") class StreamState(Enum): diff --git a/backend/airweave/domains/sync_pipeline/tests/test_factory.py b/backend/airweave/domains/sync_pipeline/tests/test_factory.py index 21d0ec11e2..8be28460c4 100644 --- a/backend/airweave/domains/sync_pipeline/tests/test_factory.py +++ b/backend/airweave/domains/sync_pipeline/tests/test_factory.py @@ -6,6 +6,8 @@ import pytest +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt from airweave.domains.sync_pipeline.factory import SourceBuildResult, SyncFactory @@ -115,7 +117,14 @@ async def test_create_orchestrator_raises_when_source_connection_missing(): ctx.organization.id = uuid4() db = AsyncMock() - with pytest.raises(NotFoundException, match="Source connection record not found"): + with ( + patch( + "airweave.domains.sync_pipeline.factory.CanonicalRecordStore.admit_job", + new_callable=AsyncMock, + return_value=0, + ), + pytest.raises(NotFoundException, match="Source connection record not found"), + ): await factory.create_orchestrator( db=db, sync=sync, @@ -132,7 +141,8 @@ async def test_create_orchestrator_raises_when_source_connection_missing(): @pytest.mark.asyncio -async def test_create_orchestrator_passes_entity_repo_to_pipeline(): +@pytest.mark.parametrize("canonical", [False, True, "page", "composed"]) +async def test_create_orchestrator_passes_entity_repo_to_pipeline(canonical): """entity_repo is forwarded to EntityActionResolver and EntityPipeline.""" entity_repo = MagicMock() sc_repo = MagicMock() @@ -158,6 +168,11 @@ async def test_create_orchestrator_passes_entity_repo_to_pipeline(): db = AsyncMock() with ( + patch( + "airweave.domains.sync_pipeline.factory.CanonicalRecordStore.admit_job", + new_callable=AsyncMock, + return_value=0, + ) as admit, patch("airweave.domains.sync_pipeline.factory.SyncContextBuilder") as mock_sc_builder, patch( "airweave.domains.sync_pipeline.factory.EntityDispatcherBuilder" @@ -176,7 +191,35 @@ async def test_create_orchestrator_passes_entity_repo_to_pipeline(): ) as mock_build_tracker, ): mock_source = MagicMock() + mock_source.capture_page_source = None mock_source.generate_entities = MagicMock(return_value=AsyncMock()) + if canonical in {"page", "composed"}: + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.platform.configs.config import SlackConfig + from airweave.platform.sources.slack import SlackSource + + mock_source = SlackSource( + auth=StaticTokenProvider("synthetic"), logger=MagicMock(), http_client=MagicMock() + ) + mock_source.slack_config = SlackConfig(expected_team_id="T1", expected_user_id="U1") + mock_source._get = AsyncMock( + return_value={"ok": True, "team_id": "T1", "user_id": "U1"} + ) + await mock_source.validate() + page_source = mock_source + if canonical == "composed": + from airweave.platform.sources.outlook_mail import OutlookMailSource + + mock_source = OutlookMailSource( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=MagicMock(), + ) + mock_source._capture_page_source = page_source + mock_source.generate_entities = MagicMock() + elif canonical: + mock_source.canonical_record_types = ("event",) + mock_source.generate_observations = MagicMock(return_value=AsyncMock()) mock_build_source.return_value = SourceBuildResult( source=mock_source, cursor=MagicMock(), files=MagicMock(), node_selections=[] ) @@ -194,11 +237,28 @@ async def test_create_orchestrator_passes_entity_repo_to_pipeline(): collection=collection, connection=connection, ctx=ctx, + capture_attempt=CaptureAttempt(id=uuid4(), number=1), ) assert orchestrator is not None - assert orchestrator.entity_pipeline._entity_repo is entity_repo - assert orchestrator.entity_pipeline._resolver._entity_repo is entity_repo + assert admit.await_count == 2 + db.refresh.assert_awaited_once_with(sc) + if canonical: + assert isinstance(orchestrator.entity_pipeline, CanonicalCapturePipeline) + mock_build_destinations.assert_not_called() + mock_disp_builder.assert_not_called() + if canonical in {"page", "composed"}: + assert orchestrator.stream is None + assert orchestrator.entity_pipeline.page_source is page_source + assert orchestrator.runtime.source is mock_source + if canonical == "composed": + mock_source.generate_entities.assert_not_called() + else: + mock_source.generate_entities.assert_not_called() + mock_source.generate_observations.assert_called_once() + else: + assert orchestrator.entity_pipeline._entity_repo is entity_repo + assert orchestrator.entity_pipeline._resolver._entity_repo is entity_repo # --------------------------------------------------------------------------- @@ -358,8 +418,11 @@ async def test_raises_not_found_when_no_arf_data(self): @pytest.mark.asyncio async def test_success_returns_source_build_result(self): - mock_source = AsyncMock() + from airweave.domains.arf.replay_source import ArfReplaySource + + mock_source = ArfReplaySource(sync_id=uuid4(), storage=MagicMock()) mock_source.validate = AsyncMock(return_value=True) + assert mock_source.capture_page_source is None factory = _build_factory() db = AsyncMock() @@ -397,10 +460,12 @@ def test_passes_for_snapshot_short_name(self): def test_passes_for_normal_source_with_invalid_snapshot_config(self): """Normal source with config that fails SnapshotConfig parsing → no exception.""" sc = SimpleNamespace(short_name="github", config_fields={"not": "snapshot"}, name="gh") - SyncFactory._validate_not_completed_snapshot(sc) # should not raise (ValidationError caught) + SyncFactory._validate_not_completed_snapshot( + sc + ) # should not raise (ValidationError caught) def test_raises_for_restored_snapshot_source(self): - """Source with non-snapshot short_name but valid SnapshotConfig fields → SyncFailureError.""" + """Restored snapshot config on a normal source raises SyncFailureError.""" from airweave.domains.sync_pipeline.exceptions import SyncFailureError sc = SimpleNamespace(short_name="github", config_fields={}, name="restored-snap") @@ -527,9 +592,7 @@ async def test_returns_tracker_with_initial_counts(self): entity_count_repo.get_counts_per_sync_and_type = AsyncMock(return_value=[count_row]) factory = _build_factory(entity_count_repo=entity_count_repo) - tracker = await factory._build_entity_tracker( - db=db, sync=sync, sync_job=sync_job, ctx=ctx - ) + tracker = await factory._build_entity_tracker(db=db, sync=sync, sync_job=sync_job, ctx=ctx) assert tracker is not None assert tracker.job_id == sync_job.id @@ -545,9 +608,7 @@ async def test_returns_tracker_with_empty_counts(self): entity_count_repo.get_counts_per_sync_and_type = AsyncMock(return_value={}) factory = _build_factory(entity_count_repo=entity_count_repo) - tracker = await factory._build_entity_tracker( - db=db, sync=sync, sync_job=sync_job, ctx=ctx - ) + tracker = await factory._build_entity_tracker(db=db, sync=sync, sync_job=sync_job, ctx=ctx) assert tracker is not None @@ -584,3 +645,49 @@ async def test_converts_rows_to_node_selection_data(self): assert len(result) == 1 assert result[0].source_node_id == "node-1" assert result[0].node_type == "file" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("during_build", [False, True]) +async def test_generation_admission_stops_stale_factory(during_build): + """Stale workflow schema zero never bypasses authoritative admission.""" + from airweave.domains.entities.canonical.store import StaleWriter + + sc = MagicMock() + sc_repo = MagicMock(get_by_sync_id=AsyncMock(return_value=sc)) + factory = _build_factory(sc_repo=sc_repo) + sync = SimpleNamespace(id=uuid4(), sync_config=None, provisioning_generation=0) + job = SimpleNamespace(id=uuid4(), sync_config=None, provisioning_generation=0) + db = AsyncMock() + order = [] + db.refresh.side_effect = lambda _: order.append("refresh") + + async def build(**kwargs): + order.append("build") + return MagicMock() + + with ( + patch( + "airweave.domains.sync_pipeline.factory.CanonicalRecordStore.admit_job", + new_callable=AsyncMock, + ) as admit, + patch.object(factory, "_build_source", side_effect=build) as source, + patch.object(factory, "_build_destinations", new_callable=AsyncMock) as destinations, + patch.object(factory, "_build_entity_tracker", new_callable=AsyncMock) as tracker, + ): + # Even if both DB generations are individually eligible, switching between + # checks must reject the source constructed under the first generation. + admit.side_effect = [4, 5] if during_build else StaleWriter("Stale job") + with pytest.raises(StaleWriter): + await factory.create_orchestrator( + db=db, + sync=sync, + sync_job=job, + collection=SimpleNamespace(sync_config=None), + connection=MagicMock(), + ctx=_make_ctx(), + ) + assert source.await_count == int(during_build) + assert order == (["refresh", "build"] if during_build else []) + destinations.assert_not_awaited() + tracker.assert_not_awaited() diff --git a/backend/airweave/domains/sync_pipeline/tests/test_orchestrator.py b/backend/airweave/domains/sync_pipeline/tests/test_orchestrator.py index cec2075275..e0a06bf20f 100644 --- a/backend/airweave/domains/sync_pipeline/tests/test_orchestrator.py +++ b/backend/airweave/domains/sync_pipeline/tests/test_orchestrator.py @@ -42,7 +42,7 @@ def _make_orchestrator(**overrides): worker_pool=worker_pool, stream=overrides.pop("stream", MagicMock()), sync_context=sync_context, - runtime=overrides.pop("runtime", MagicMock()), + runtime=overrides.pop("runtime", MagicMock(canonical_capture=None)), access_control_pipeline=overrides.pop("access_control_pipeline", MagicMock()), event_bus=overrides.pop("event_bus", MagicMock()), usage_checker=usage_checker, @@ -468,3 +468,32 @@ async def _fake_handler(_e: Exception): ): with pytest.raises(RuntimeError, match="redis down"): await orc.run() + + +async def test_checkpoint_failure_prevents_completed_transition(): + """A successfully processed stream is incomplete if checkpoint persistence fails.""" + service = MagicMock() + service.create_or_update_cursor = AsyncMock(side_effect=ConnectionError("database unavailable")) + state_machine = MagicMock() + state_machine.transition = AsyncMock() + ctx = _make_sync_context() + ctx.execution_config = None + runtime = MagicMock() + runtime.canonical_capture = None + runtime.cursor = SimpleNamespace(cursor_data={"history_id": "42"}, cursor_field=None) + orch = _make_orchestrator( + sync_context=ctx, + runtime=runtime, + sync_cursor_service=service, + state_machine=state_machine, + ) + db_context = MagicMock() + db_context.__aenter__ = AsyncMock(return_value=MagicMock()) + db_context.__aexit__ = AsyncMock(return_value=False) + with patch( + "airweave.domains.sync_pipeline.orchestrator.get_db_context", return_value=db_context + ): + with pytest.raises(ConnectionError, match="database unavailable"): + await orch._complete_sync() + service.create_or_update_cursor.assert_awaited_once() + state_machine.transition.assert_not_awaited() diff --git a/backend/airweave/domains/sync_pipeline/tests/test_orchestrator_coverage.py b/backend/airweave/domains/sync_pipeline/tests/test_orchestrator_coverage.py index 2bf4a0a241..0d63dcb826 100644 --- a/backend/airweave/domains/sync_pipeline/tests/test_orchestrator_coverage.py +++ b/backend/airweave/domains/sync_pipeline/tests/test_orchestrator_coverage.py @@ -114,6 +114,7 @@ async def empty_stream(): stream.get_entities = empty_stream runtime = MagicMock() + runtime.canonical_capture = None runtime.source.source_name = "test_source" runtime.source.short_name = "test_source" runtime.source.supports_access_control = False diff --git a/backend/airweave/domains/sync_pipeline/tests/test_text_builder.py b/backend/airweave/domains/sync_pipeline/tests/test_text_builder.py index de1193451d..0b942e65cb 100644 --- a/backend/airweave/domains/sync_pipeline/tests/test_text_builder.py +++ b/backend/airweave/domains/sync_pipeline/tests/test_text_builder.py @@ -4,7 +4,6 @@ from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock -from uuid import uuid4 import pytest @@ -57,7 +56,7 @@ async def test_sync_failure_error_propagates(self): entity = _make_entity() with pytest.raises(SyncFailureError, match="infra down"): - await builder._convert_sub_batch(converter, [(entity, "/path")], ctx) + await builder._convert_sub_batch(converter, [(entity, "/path")], ctx, {}) @pytest.mark.asyncio async def test_entity_processing_error_skips_sub_batch(self): @@ -67,7 +66,7 @@ async def test_entity_processing_error_skips_sub_batch(self): ctx = FakeSyncContext() entity = _make_entity() - failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx) + failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx, {}) assert failures == [entity] ctx.logger.warning.assert_called() @@ -80,7 +79,7 @@ async def test_generic_exception_marks_failed_but_continues(self): ctx = FakeSyncContext() entity = _make_entity() - failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx) + failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx, {}) assert failures == [entity] ctx.logger.error.assert_called() @@ -93,7 +92,7 @@ async def test_successful_conversion_appends_content(self): ctx = FakeSyncContext() entity = _make_entity() - failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx) + failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx, {}) assert failures == [] assert "Hello world content" in entity.textual_representation @@ -106,7 +105,7 @@ async def test_no_content_returns_entity_as_failure(self): ctx = FakeSyncContext() entity = _make_entity() - failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx) + failures = await builder._convert_sub_batch(converter, [(entity, "/path")], ctx, {}) assert failures == [entity] @@ -154,8 +153,8 @@ def test_file_entity_unsupported_extension_raises(self): builder._get_converter_and_key(entity) def test_file_entity_no_converter_for_extension_raises(self): - from airweave.platform.entities._base import FileEntity from airweave.domains.sync_pipeline.file_types import SUPPORTED_FILE_EXTENSIONS + from airweave.platform.entities._base import FileEntity supported_ext = next(iter(SUPPORTED_FILE_EXTENSIONS)) registry = MagicMock() diff --git a/backend/airweave/domains/syncs/cursors/service.py b/backend/airweave/domains/syncs/cursors/service.py index c362553abb..d86317ded7 100644 --- a/backend/airweave/domains/syncs/cursors/service.py +++ b/backend/airweave/domains/syncs/cursors/service.py @@ -32,7 +32,7 @@ async def get_cursor_data(self, db: AsyncSession, sync_id: UUID, ctx: BaseContex return {} except Exception as e: logger.warning(f"Failed to load cursor data for sync {sync_id}: {e}") - return {} + raise async def get_cursor_field( self, db: AsyncSession, sync_id: UUID, ctx: ApiContext @@ -54,7 +54,7 @@ async def get_cursor_field( return None except Exception as e: logger.warning(f"Failed to load cursor field for sync {sync_id}: {e}") - return None + raise async def create_or_update_cursor( self, @@ -63,7 +63,7 @@ async def create_or_update_cursor( cursor_data: dict, ctx: ApiContext, cursor_field: Optional[str] = None, - ) -> Optional[schemas.SyncCursor]: + ) -> schemas.SyncCursor: """Create or update cursor data for a sync. Args: @@ -74,7 +74,7 @@ async def create_or_update_cursor( cursor_field: Optional cursor field name Returns: - Created or updated sync cursor, None if operation failed + Created or updated sync cursor. Persistence failures propagate to the caller. """ try: cursor_create = schemas.SyncCursorCreate( @@ -93,7 +93,7 @@ async def create_or_update_cursor( except Exception as e: logger.error(f"Failed to create/update cursor for sync {sync_id}: {e}") - return None + raise async def update_cursor_data( self, @@ -111,7 +111,7 @@ async def update_cursor_data( ctx: API context Returns: - Updated sync cursor, None if operation failed + Updated sync cursor, or None if no cursor exists. Persistence failures propagate. """ try: cursor = await crud.sync_cursor.update_cursor_data( @@ -127,7 +127,7 @@ async def update_cursor_data( except Exception as e: logger.error(f"Failed to update cursor data for sync {sync_id}: {e}") - return None + raise async def delete_cursor( self, @@ -157,4 +157,4 @@ async def delete_cursor( except Exception as e: logger.error(f"Failed to delete cursor for sync {sync_id}: {e}") - return False + raise diff --git a/backend/airweave/domains/syncs/cursors/tests/test_failure_semantics.py b/backend/airweave/domains/syncs/cursors/tests/test_failure_semantics.py new file mode 100644 index 0000000000..9f0b03ca0c --- /dev/null +++ b/backend/airweave/domains/syncs/cursors/tests/test_failure_semantics.py @@ -0,0 +1,39 @@ +"""Persistence failure must never masquerade as an empty or saved checkpoint.""" + +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from airweave.domains.syncs.cursors.service import SyncCursorService + + +@pytest.mark.parametrize( + ("operation", "crud_operation", "extra"), + [ + ("get_cursor_data", "get_by_sync_id", {}), + ("get_cursor_field", "get_by_sync_id", {}), + ("create_or_update_cursor", "create_or_update", {"cursor_data": {"position": "42"}}), + ("update_cursor_data", "update_cursor_data", {"cursor_data": {"position": "42"}}), + ("delete_cursor", "delete_by_sync_id", {}), + ], +) +async def test_database_failure_propagates(operation, crud_operation, extra): + failure = ConnectionError("database unavailable") + with patch( + f"airweave.domains.syncs.cursors.service.crud.sync_cursor.{crud_operation}", + new=AsyncMock(side_effect=failure), + ): + with pytest.raises(ConnectionError) as raised: + await getattr(SyncCursorService(), operation)( + db=MagicMock(), sync_id=uuid4(), ctx=MagicMock(), **extra + ) + assert raised.value is failure + + +async def test_missing_checkpoint_is_distinct_from_database_failure(): + with patch( + "airweave.domains.syncs.cursors.service.crud.sync_cursor.get_by_sync_id", + new=AsyncMock(return_value=None), + ): + assert await SyncCursorService().get_cursor_data(MagicMock(), uuid4(), MagicMock()) == {} diff --git a/backend/airweave/domains/syncs/fakes/repository.py b/backend/airweave/domains/syncs/fakes/repository.py index 66e3f6ad74..6b080be045 100644 --- a/backend/airweave/domains/syncs/fakes/repository.py +++ b/backend/airweave/domains/syncs/fakes/repository.py @@ -74,9 +74,10 @@ async def create( obj_in: SyncCreate, ctx: ApiContext, uow: Optional[UnitOfWork] = None, + initial_pipeline_version: int = 1, ) -> schemas.Sync: """Record call and return canned result.""" - self._calls.append(("create", db, obj_in, ctx, uow)) + self._calls.append(("create", db, obj_in, ctx, uow, initial_pipeline_version)) if self._create_result is None: raise RuntimeError("FakeSyncRepository.create_result not configured") return self._create_result diff --git a/backend/airweave/domains/syncs/fakes/service.py b/backend/airweave/domains/syncs/fakes/service.py index eade30d2e8..9ebfc3b6f9 100644 --- a/backend/airweave/domains/syncs/fakes/service.py +++ b/backend/airweave/domains/syncs/fakes/service.py @@ -3,6 +3,7 @@ from typing import Dict, List, Optional, Tuple from uuid import UUID +from pydantic import JsonValue from sqlalchemy.ext.asyncio import AsyncSession from airweave import schemas @@ -73,6 +74,8 @@ async def create( run_immediately: bool, ctx: ApiContext, uow: UnitOfWork, + defer_execution: bool = False, + source_config: dict[str, JsonValue] | None = None, ) -> SyncProvisionResult: self._calls.append(("create", name, source_connection_id, collection_id)) if self._should_raise: diff --git a/backend/airweave/domains/syncs/protocols.py b/backend/airweave/domains/syncs/protocols.py index 7d17b55fe9..5f93139e86 100644 --- a/backend/airweave/domains/syncs/protocols.py +++ b/backend/airweave/domains/syncs/protocols.py @@ -5,6 +5,7 @@ from typing import List, Optional, Protocol, Tuple from uuid import UUID +from pydantic import JsonValue from sqlalchemy.ext.asyncio import AsyncSession from airweave import schemas @@ -53,6 +54,7 @@ async def create( obj_in: SyncCreate, ctx: ApiContext, uow: Optional[UnitOfWork] = None, + initial_pipeline_version: int = 1, ) -> schemas.Sync: """Create a new sync with its connection associations.""" ... @@ -122,6 +124,8 @@ async def create( run_immediately: bool, ctx: ApiContext, uow: UnitOfWork, + defer_execution: bool = False, + source_config: dict[str, JsonValue] | None = None, ) -> SyncProvisionResult: """Create sync + optional job + Temporal schedule atomically.""" ... diff --git a/backend/airweave/domains/syncs/repository.py b/backend/airweave/domains/syncs/repository.py index e48edc2f2f..0b09870acd 100644 --- a/backend/airweave/domains/syncs/repository.py +++ b/backend/airweave/domains/syncs/repository.py @@ -52,9 +52,16 @@ async def create( obj_in: SyncCreate, ctx: ApiContext, uow: Optional[UnitOfWork] = None, + initial_pipeline_version: int = 1, ) -> schemas.Sync: """Create a new sync with its connection associations.""" - return await crud.sync.create(db=db, obj_in=obj_in, ctx=ctx, uow=uow) + return await crud.sync.create( + db=db, + obj_in=obj_in, + ctx=ctx, + uow=uow, + initial_pipeline_version=initial_pipeline_version, + ) async def update( self, diff --git a/backend/airweave/domains/syncs/service.py b/backend/airweave/domains/syncs/service.py index f8315b2a3b..8ed9a5bab9 100644 --- a/backend/airweave/domains/syncs/service.py +++ b/backend/airweave/domains/syncs/service.py @@ -16,6 +16,7 @@ from uuid import UUID from fastapi import HTTPException +from pydantic import JsonValue from sqlalchemy.ext.asyncio import AsyncSession from airweave import schemas @@ -29,6 +30,10 @@ ) from airweave.db.session import get_db_context from airweave.db.unit_of_work import UnitOfWork +from airweave.domains.entities.canonical.page_source import CanonicalPageSource +from airweave.domains.entities.canonical.search_metadata import SEARCH_METADATA_PIPELINE_VERSION +from airweave.domains.entities.canonical.source import CanonicalSource +from airweave.domains.owned_provisioning.guard import require_provider_sync from airweave.domains.sources.exceptions.classifier import classify_error from airweave.domains.sources.types import SourceRegistryEntry from airweave.domains.sync_pipeline.config import SyncConfig @@ -54,6 +59,7 @@ TemporalScheduleServiceProtocol, TemporalWorkflowServiceProtocol, ) +from airweave.platform.configs.config import OutlookCalendarConfig, OutlookMailConfig, StripeConfig from airweave.schemas.source_connection import ScheduleConfig from airweave.schemas.sync import SyncCreate from airweave.schemas.sync_job import SyncJobCreate @@ -106,6 +112,8 @@ async def create( run_immediately: bool, ctx: ApiContext, uow: UnitOfWork, + defer_execution: bool = False, + source_config: dict[str, JsonValue] | None = None, ) -> SyncProvisionResult: """Create sync + optional job + Temporal schedule atomically. @@ -124,6 +132,22 @@ async def create( if cron: self._validate_cron_for_source(cron, source_entry) + canonical = isinstance( + source_entry.source_class_ref, (CanonicalSource, CanonicalPageSource) + ) + if source_entry.short_name in {"outlook_mail", "outlook_calendar"}: + # Outlook composes its canonical adapter only for explicit original capture. + config_type = ( + OutlookMailConfig + if source_entry.short_name == "outlook_mail" + else OutlookCalendarConfig + ) + canonical = config_type.model_validate(source_config or {}).capture_originals + elif source_entry.short_name == "stripe": + canonical = ( + StripeConfig.model_validate(source_config or {}).original_capture is not None + ) + sync_schema, sync_job_schema = await self._create_sync_records( uow.session, name=f"Sync for {name}", @@ -131,11 +155,12 @@ async def create( destination_connection_ids=destination_connection_ids, cron_schedule=cron, run_immediately=run_immediately, + initial_pipeline_version=(SEARCH_METADATA_PIPELINE_VERSION if canonical else 1), ctx=ctx, uow=uow, ) - if cron: + if cron and not defer_execution: await self._temporal_schedule_service.create_or_update_schedule( sync_id=sync_schema.id, cron_schedule=cron, @@ -306,6 +331,8 @@ async def cancel_job( detail=f"Cannot cancel job in {sync_job.status} state", ) + await require_provider_sync(db, sync_job.sync_id, ctx.organization.id) + if sync_job.status == SyncJobStatus.PENDING: # PENDING → CANCELLED directly (PENDING → CANCELLING is invalid) await self._job_state_machine.transition( @@ -470,6 +497,7 @@ async def _create_sync_records( destination_connection_ids: List[UUID], cron_schedule: Optional[str], run_immediately: bool, + initial_pipeline_version: int, ctx: ApiContext, uow: UnitOfWork, ) -> Tuple[schemas.Sync, Optional[schemas.SyncJob]]: @@ -486,7 +514,13 @@ async def _create_sync_records( run_immediately=run_immediately, ) - sync_schema = await self._sync_repo.create(uow.session, obj_in=sync_in, ctx=ctx, uow=uow) + sync_schema = await self._sync_repo.create( + uow.session, + obj_in=sync_in, + ctx=ctx, + uow=uow, + initial_pipeline_version=initial_pipeline_version, + ) await uow.session.flush() sync_job_schema: Optional[schemas.SyncJob] = None diff --git a/backend/airweave/domains/syncs/tests/test_service.py b/backend/airweave/domains/syncs/tests/test_service.py index ae0e4d4efa..7b7bb849db 100644 --- a/backend/airweave/domains/syncs/tests/test_service.py +++ b/backend/airweave/domains/syncs/tests/test_service.py @@ -825,7 +825,9 @@ async def test_cancel_job_not_found(): svc = _build_svc(sync_job_repo=job_repo) with pytest.raises(HTTPException) as exc_info: - await svc.cancel_job(AsyncMock(), job_id=uuid4(), ctx=_mock_ctx()) + await svc.cancel_job( + AsyncMock(scalar=AsyncMock(return_value=None)), job_id=uuid4(), ctx=_mock_ctx() + ) assert exc_info.value.status_code == 404 @@ -838,7 +840,9 @@ async def test_cancel_job_wrong_status(): svc = _build_svc(sync_job_repo=job_repo) with pytest.raises(HTTPException) as exc_info: - await svc.cancel_job(AsyncMock(), job_id=uuid4(), ctx=_mock_ctx()) + await svc.cancel_job( + AsyncMock(scalar=AsyncMock(return_value=None)), job_id=uuid4(), ctx=_mock_ctx() + ) assert exc_info.value.status_code == 400 @@ -856,7 +860,7 @@ async def test_cancel_job_success(): } job_sm = AsyncMock() - db = AsyncMock() + db = AsyncMock(scalar=AsyncMock(return_value=None)) svc = _build_svc( sync_job_repo=job_repo, @@ -880,7 +884,7 @@ async def test_cancel_pending_job_transitions_directly_to_cancelled(): temporal = AsyncMock() job_sm = AsyncMock() - db = AsyncMock() + db = AsyncMock(scalar=AsyncMock(return_value=None)) svc = _build_svc( sync_job_repo=job_repo, @@ -909,7 +913,7 @@ async def test_cancel_job_workflow_not_found_marks_cancelled(): } job_sm = AsyncMock() - db = AsyncMock() + db = AsyncMock(scalar=AsyncMock(return_value=None)) svc = _build_svc( sync_job_repo=job_repo, @@ -942,7 +946,9 @@ async def test_cancel_job_temporal_failure(): svc = _build_svc(sync_job_repo=job_repo, temporal_workflow_service=temporal) with pytest.raises(HTTPException) as exc_info: - await svc.cancel_job(AsyncMock(), job_id=job_id, ctx=_mock_ctx()) + await svc.cancel_job( + AsyncMock(scalar=AsyncMock(return_value=None)), job_id=job_id, ctx=_mock_ctx() + ) assert exc_info.value.status_code == 502 @@ -953,6 +959,7 @@ async def test_cancel_job_temporal_failure(): def _mock_source_entry(*, short_name="github", continuous=False, federated=False): entry = MagicMock() + entry.source_class_ref = object entry.short_name = short_name entry.supports_continuous = continuous entry.federated_search = federated @@ -1065,7 +1072,31 @@ async def test_create_no_cron_no_run_immediately(): @pytest.mark.asyncio -async def test_create_with_cron_calls_temporal_schedule(): +@pytest.mark.parametrize( + "source_kind, source_config, expected_version", + [ + ("legacy", None, 1), + ("gmail", None, 2), + ("slack", None, 2), + ("outlook_mail", None, 1), + ("stripe", None, 1), + ( + "stripe", + { + "original_capture": { + "expected_account_id": "acct_selected", + "livemode": False, + "api_version": "2025-06-30.basil", + } + }, + 2, + ), + ("outlook_mail", {"capture_originals": True, "expected_principal_id": "native-owner"}, 2), + ], +) +async def test_create_with_cron_calls_temporal_schedule( + source_kind, source_config, expected_version +): from airweave.schemas.source_connection import ScheduleConfig sync_repo = AsyncMock() @@ -1086,6 +1117,19 @@ async def test_create_with_cron_calls_temporal_schedule(): temporal_schedule_service=temporal_sched, ) + from airweave.platform.sources.gmail import GmailSource + from airweave.platform.sources.outlook_mail import OutlookMailSource + from airweave.platform.sources.slack import SlackSource + from airweave.platform.sources.stripe import StripeSource + + source_entry = _mock_source_entry(short_name=source_kind) + source_entry.source_class_ref = { + "legacy": object, + "gmail": GmailSource, + "slack": SlackSource, + "outlook_mail": OutlookMailSource, + "stripe": StripeSource, + }[source_kind] result = await svc.create( AsyncMock(), name="test", @@ -1093,7 +1137,8 @@ async def test_create_with_cron_calls_temporal_schedule(): destination_connection_ids=[uuid4()], collection_id=uuid4(), collection_readable_id="col-x", - source_entry=_mock_source_entry(), + source_entry=source_entry, + source_config=source_config, schedule_config=ScheduleConfig(cron="0 6 * * *"), run_immediately=False, ctx=_mock_ctx(), @@ -1102,6 +1147,8 @@ async def test_create_with_cron_calls_temporal_schedule(): assert result is not None assert result.sync_id == mock_sync.id temporal_sched.create_or_update_schedule.assert_awaited_once() + assert sync_repo.create.call_args.kwargs["initial_pipeline_version"] == expected_version + assert "index_pipeline_version" not in sync_repo.create.call_args.kwargs["obj_in"].model_dump() # --------------------------------------------------------------------------- @@ -1202,3 +1249,19 @@ async def test_schedule_cleanup_handles_error(): ctx = _mock_ctx() await svc._schedule_cleanup(uuid4(), uuid4(), uuid4(), ctx) ctx.logger.error.assert_called_once() + + +async def test_provider_cancellation_rejects_native_job_before_external_actions(): + job_repo, temporal, state = AsyncMock(), AsyncMock(), AsyncMock() + job = _orm_sync_job(job_id=uuid4(), status=SyncJobStatus.RUNNING) + job_repo.get.return_value = job + svc = _build_svc( + sync_job_repo=job_repo, temporal_workflow_service=temporal, job_state_machine=state + ) + with pytest.raises(HTTPException) as error: + await svc.cancel_job( + AsyncMock(scalar=AsyncMock(return_value=uuid4())), job_id=job.id, ctx=_mock_ctx() + ) + assert error.value.status_code == 409 + state.transition.assert_not_awaited() + temporal.cancel_sync_job_workflow.assert_not_awaited() diff --git a/backend/airweave/domains/temporal/activities/cleanup_projection_generations.py b/backend/airweave/domains/temporal/activities/cleanup_projection_generations.py new file mode 100644 index 0000000000..e810c8b789 --- /dev/null +++ b/backend/airweave/domains/temporal/activities/cleanup_projection_generations.py @@ -0,0 +1,52 @@ +"""Bounded reclamation on the existing system maintenance workflow.""" + +from dataclasses import dataclass +from datetime import datetime, timezone + +from temporalio import activity + +from airweave.db.session import get_db_context +from airweave.domains.entities.canonical.projection_gc import ProjectionGCStore +from airweave.domains.storage.protocols import StorageBackend +from airweave.platform.destinations.vespa.client import VespaClient + + +@dataclass +class CleanupProjectionGenerationsActivity: + """A tick deletes at most 100 exact document IDs; failures remain durable.""" + + storage: StorageBackend + + @activity.defn(name="cleanup_projection_generations_activity") + async def run(self) -> None: + """Use existing SQL/Temporal infrastructure, without creating schedules here.""" + store = ProjectionGCStore() + now = datetime.now(timezone.utc) + async with get_db_context() as db: + due = await store.due(db, now=now, limit=20) + if not due: + return + client = await VespaClient.connect() + remaining = 100 + try: + for generation in due: + if remaining <= 0: + break + async with get_db_context() as db: + page = await store.claim(db, generation, now=now, limit=remaining) + if page is None: + continue + error = None + try: + await client.delete_documents( + [(doc.schema_name, doc.document_id) for doc in page.documents] + ) + for key in page.artifact_keys: + await self.storage.delete_file(key) + except Exception as failure: + error = type(failure).__name__ + async with get_db_context() as db: + await store.acknowledge(db, page, now=datetime.now(timezone.utc), error=error) + remaining -= len(page.documents) + len(page.artifact_keys) + finally: + await client.close() diff --git a/backend/airweave/domains/temporal/activities/discover_native_projection.py b/backend/airweave/domains/temporal/activities/discover_native_projection.py new file mode 100644 index 0000000000..33ed3c7007 --- /dev/null +++ b/backend/airweave/domains/temporal/activities/discover_native_projection.py @@ -0,0 +1,36 @@ +"""Discover committed canonical work without a second dispatch journal.""" + +from dataclasses import dataclass +from uuid import UUID + +from temporalio import activity + +from airweave.db.session import get_db_context +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.source import indexed_record_types +from airweave.domains.sources.protocols import SourceRegistryProtocol + + +@dataclass +class DiscoverNativeProjectionActivity: + """Read the existing pending state; no credentials or content cross Temporal.""" + + source_registry: SourceRegistryProtocol + + @activity.defn(name="discover_native_projection_activity") + async def run(self, after_id: str | None = None) -> dict: + """Discover native imports and provider records even while capture is active.""" + source_names = ("almanac",) + tuple( + entry.short_name + for entry in self.source_registry.list_all() + if entry.short_name != "almanac" + and indexed_record_types(entry.short_name, self.source_registry) + ) + async with get_db_context() as db: + page = await CanonicalProjectionStore().pending_sources( + db, + source_names=source_names, + after_id=UUID(after_id) if after_id else None, + limit=20, + ) + return page.model_dump(mode="json") diff --git a/backend/airweave/domains/temporal/activities/project_canonical_records.py b/backend/airweave/domains/temporal/activities/project_canonical_records.py new file mode 100644 index 0000000000..5dab931d0d --- /dev/null +++ b/backend/airweave/domains/temporal/activities/project_canonical_records.py @@ -0,0 +1,73 @@ +"""Project owned records without reconnecting or consulting the provider.""" + +from dataclasses import dataclass +from uuid import UUID + +from temporalio import activity + +from airweave.core.logging import logger +from airweave.db.session import get_db_context +from airweave.domains.entities.canonical.projection_models import ProjectionBatchResult +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.source import indexed_record_types +from airweave.domains.sources.protocols import SourceRegistryProtocol +from airweave.platform.destinations.vespa.destination import VespaDestination + + +@dataclass +class ProjectCanonicalRecordsActivity: + """One bounded page; Temporal retries infrastructure failures independently.""" + + projector: CanonicalProjector + source_registry: SourceRegistryProtocol + + @activity.defn(name="project_canonical_records_activity") + async def run( + self, + organization_id: str, + sync_id: str, + after_id: str | None = None, + skip_failed: bool = False, + ) -> dict: + """Resolve tenant collection scope fresh; never receive OAuth credentials.""" + organization = UUID(organization_id) + sync = UUID(sync_id) + async with get_db_context() as db: + binding = await CanonicalProjectionStore().binding(db, organization, sync) + if binding is None: + return ProjectionBatchResult().model_dump(mode="json") + source_name, collection_id = binding.source_name, binding.collection_id + if not indexed_record_types(source_name, self.source_registry): + return ProjectionBatchResult().model_dump(mode="json") + # Do not construct an index client for sources with no pending records. + pending = await CanonicalProjectionStore().pending( + db, + organization, + sync, + after_id=UUID(after_id) if after_id else None, + limit=1, + skip_failed=skip_failed, + ) + if not pending: + return ProjectionBatchResult().model_dump(mode="json") + destination = await VespaDestination.create( + collection_id=collection_id, + organization_id=organization, + logger=logger, + soft_fail=False, + ) + try: + options = {"skip_failed": True} if skip_failed else {} + result = await self.projector.batch( + organization, + sync, + source_name, + destination, + logger, + after_id=UUID(after_id) if after_id else None, + **options, + ) + return result.model_dump(mode="json") + finally: + await destination.close_connection() diff --git a/backend/airweave/domains/temporal/activities/tests/test_discover_native_projection.py b/backend/airweave/domains/temporal/activities/tests/test_discover_native_projection.py new file mode 100644 index 0000000000..d26ed38fe2 --- /dev/null +++ b/backend/airweave/domains/temporal/activities/tests/test_discover_native_projection.py @@ -0,0 +1,43 @@ +"""Discovery uses registered indexing capabilities, never provider authentication calls.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +from airweave.domains.entities.canonical.projection_models import ProjectionSourcePage +from airweave.domains.temporal.activities import discover_native_projection as module + + +async def test_discovers_native_and_canonical_capabilities_with_bounded_cursor(monkeypatch): + entries = { + name: SimpleNamespace( + short_name=name, + source_class_ref=SimpleNamespace(canonical_record_types=kinds), + ) + for name, kinds in ( + ("slack", ("channel", "message", "file")), + ("gmail", ("message",)), + ("legacy", ()), + ) + } + registry = Mock() + registry.list_all.return_value = list(entries.values()) + registry.get.side_effect = entries.__getitem__ + db = object() + + @asynccontextmanager + async def sessions(): + yield db + + pending = AsyncMock(return_value=ProjectionSourcePage(sources=(), next_cursor=None)) + monkeypatch.setattr(module, "get_db_context", sessions) + monkeypatch.setattr( + module, "CanonicalProjectionStore", lambda: SimpleNamespace(pending_sources=pending) + ) + after = uuid4() + result = await module.DiscoverNativeProjectionActivity(registry).run(str(after)) + assert result == {"sources": [], "next_cursor": None} + pending.assert_awaited_once_with( + db, source_names=("almanac", "slack", "gmail"), after_id=after, limit=20 + ) diff --git a/backend/airweave/domains/temporal/activities/tests/test_project_canonical_records.py b/backend/airweave/domains/temporal/activities/tests/test_project_canonical_records.py new file mode 100644 index 0000000000..83d507354d --- /dev/null +++ b/backend/airweave/domains/temporal/activities/tests/test_project_canonical_records.py @@ -0,0 +1,147 @@ +"""Real source/collection scope resolution; only the remote projector is replaced.""" + +# ruff: noqa: F811 +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import UUID, uuid4 + +import pytest + +from airweave.domains.entities.canonical.projection_models import ProjectionBatchResult +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.tests.conftest import database, source # noqa: F401 +from airweave.domains.entities.canonical.tests.helpers import capture, observation +from airweave.domains.temporal.activities import project_canonical_records as module +from airweave.models import Collection, SourceConnection +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata + + +@pytest.fixture +async def activity_setup(database, source, monkeypatch): + service, fence = source + metadata = VectorDbDeploymentMetadata( + dense_embedder="test", sparse_embedder="test", embedding_dimensions=3 + ) + collection_id, connection_id = uuid4(), uuid4() + async with database() as db: + db.add(metadata) + await db.flush() + db.add( + Collection( + id=collection_id, + name="Native", + readable_id="native", + organization_id=fence.organization_id, + vector_db_deployment_metadata_id=metadata.id, + ) + ) + db.add( + SourceConnection( + id=connection_id, + organization_id=fence.organization_id, + sync_id=fence.sync_id, + name="Native", + is_authenticated=True, + short_name="almanac", + readable_collection_id="native", + ) + ) + await db.commit() + await capture( + database, + service, + fence, + observation( + identity=RecordIdentity(record_type="knowledge", native_id="one"), + payload={"body": "नमस्ते"}, + ), + ) + monkeypatch.setattr(module, "get_db_context", database) + destination = SimpleNamespace(close_connection=AsyncMock()) + create = AsyncMock(return_value=destination) + monkeypatch.setattr(module.VespaDestination, "create", create) + projector = SimpleNamespace(batch=AsyncMock(return_value=ProjectionBatchResult(published=1))) + registry = Mock() + registry.get.side_effect = AssertionError("Native sources must not enter provider registry") + activity = module.ProjectCanonicalRecordsActivity(projector, registry) + return activity, fence, connection_id, collection_id, destination, create + + +@pytest.mark.parametrize("provider", ["almanac", "gmail", "legacy"]) +async def test_capability_routes_native_and_provider(activity_setup, database, provider): + activity, fence, connection_id, collection_id, destination, create = activity_setup + if provider != "almanac": + async with database() as db: + connection = await db.get(SourceConnection, connection_id) + connection.short_name = provider + await db.commit() + activity.source_registry.get.side_effect = None + activity.source_registry.get.return_value = SimpleNamespace( + source_class_ref=SimpleNamespace( + canonical_record_types=("message",) if provider == "gmail" else () + ) + ) + result = await activity.run(str(fence.organization_id), str(fence.sync_id), str(UUID(int=0))) + if provider == "legacy": + assert result == ProjectionBatchResult().model_dump(mode="json") + create.assert_not_awaited() + activity.projector.batch.assert_not_awaited() + return + assert result["published"] == 1 + create.assert_awaited_once_with( + collection_id=collection_id, + organization_id=fence.organization_id, + logger=module.logger, + soft_fail=False, + ) + activity.projector.batch.assert_awaited_once_with( + fence.organization_id, + fence.sync_id, + provider, + destination, + module.logger, + after_id=UUID(int=0), + ) + destination.close_connection.assert_awaited_once() + if provider == "almanac": + activity.source_registry.get.assert_not_called() + else: + activity.source_registry.get.assert_called_once_with(provider) + + +async def test_foreign_organization_and_empty_page_do_not_connect(activity_setup): + activity, fence, _, _, destination, create = activity_setup + assert await activity.run( + str(uuid4()), str(fence.sync_id) + ) == ProjectionBatchResult().model_dump(mode="json") + # This valid source has no pending records beyond the maximum UUID. + assert await activity.run( + str(fence.organization_id), str(fence.sync_id), str(UUID(int=2**128 - 1)) + ) == ProjectionBatchResult().model_dump(mode="json") + activity.source_registry.get.assert_not_called() + activity.projector.batch.assert_not_awaited() + create.assert_not_awaited() + destination.close_connection.assert_not_awaited() + + +async def test_projector_failure_propagates_and_closes_destination(activity_setup): + activity, fence, _, _, destination, _ = activity_setup + activity.projector.batch.side_effect = RuntimeError("synthetic projection failure") + with pytest.raises(RuntimeError, match="synthetic projection failure"): + await activity.run(str(fence.organization_id), str(fence.sync_id)) + destination.close_connection.assert_awaited_once() + + +async def test_deauthenticated_binding_never_constructs_destination(activity_setup, database): + activity, fence, connection_id, _, destination, create = activity_setup + async with database() as db: + connection = await db.get(SourceConnection, connection_id) + connection.is_authenticated = False + await db.commit() + assert await activity.run(str(fence.organization_id), str(fence.sync_id)) == ( + ProjectionBatchResult().model_dump(mode="json") + ) + activity.source_registry.get.assert_not_called() + activity.projector.batch.assert_not_awaited() + create.assert_not_awaited() + destination.close_connection.assert_not_awaited() diff --git a/backend/airweave/domains/temporal/protocols.py b/backend/airweave/domains/temporal/protocols.py index 519423331d..b936567b8b 100644 --- a/backend/airweave/domains/temporal/protocols.py +++ b/backend/airweave/domains/temporal/protocols.py @@ -82,6 +82,9 @@ async def delete_all_schedules_for_sync( sync_id: UUID, db: AsyncSession, ctx: ApiContext, + *, + uow: Optional[UnitOfWork] = None, + strict: bool = False, ) -> None: """Delete all schedules associated with a sync.""" ... diff --git a/backend/airweave/domains/temporal/schedule_service.py b/backend/airweave/domains/temporal/schedule_service.py index 5d5fedf7e6..2615b80f84 100644 --- a/backend/airweave/domains/temporal/schedule_service.py +++ b/backend/airweave/domains/temporal/schedule_service.py @@ -200,7 +200,6 @@ async def _create_schedule( { "temporal_schedule_id": schedule_id, "sync_type": sync_type, - "status": "ACTIVE", "cron_schedule": cron_expression, }, ctx, @@ -265,11 +264,16 @@ async def _delete_schedule_by_id( sync_id: UUID, db: AsyncSession, ctx: ApiContext, + uow: Optional[UnitOfWork] = None, ) -> None: """Delete a single Temporal schedule and clear sync DB fields.""" client = await self._get_client() handle = client.get_schedule_handle(schedule_id) - await handle.delete() + try: + await handle.delete() + except RPCError as exc: + if exc.status != RPCStatusCode.NOT_FOUND: + raise sync_obj = await self._sync_repo.get_without_connections(db, sync_id, ctx) await self._sync_repo.update( @@ -281,6 +285,7 @@ async def _delete_schedule_by_id( "sync_type": "full", }, ctx, + uow=uow, ) logger.info(f"Deleted schedule {schedule_id}") @@ -385,7 +390,7 @@ async def create_or_update_schedule( if sync.temporal_schedule_id: status = await self._check_schedule_exists(sync.temporal_schedule_id) if status["exists"]: - await self.delete_all_schedules_for_sync(sync_id, db, ctx) + await self.delete_all_schedules_for_sync(sync_id, db, ctx, uow=uow, strict=True) else: logger.warning( f"Schedule {sync.temporal_schedule_id} not found in Temporal " @@ -430,12 +435,17 @@ async def delete_all_schedules_for_sync( sync_id: UUID, db: AsyncSession, ctx: ApiContext, + *, + uow: Optional[UnitOfWork] = None, + strict: bool = False, ) -> None: """Delete all schedules (regular + minute + daily cleanup) for a sync.""" for sid in sched_ids.all_schedule_ids(sync_id): try: - await self._delete_schedule_by_id(sid, sync_id, db, ctx) + await self._delete_schedule_by_id(sid, sync_id, db, ctx, uow=uow) except Exception as e: + if strict: + raise logger.info(f"Schedule {sid} not deleted (may not exist): {e}") async def delete_schedule_handle(self, schedule_id: str) -> None: diff --git a/backend/airweave/domains/temporal/service.py b/backend/airweave/domains/temporal/service.py index 48a38d69c9..a1ac484ae1 100644 --- a/backend/airweave/domains/temporal/service.py +++ b/backend/airweave/domains/temporal/service.py @@ -12,6 +12,7 @@ from typing import List, Optional from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDReusePolicy from temporalio.service import RPCError, RPCStatusCode from airweave import schemas @@ -79,6 +80,7 @@ async def run_source_connection_workflow( force_full_sync, ], id=workflow_id, + id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE, task_queue=settings.TEMPORAL_TASK_QUEUE, ) diff --git a/backend/airweave/domains/temporal/tests/test_temporal_schedule_service.py b/backend/airweave/domains/temporal/tests/test_temporal_schedule_service.py index 4bd79df8fa..cb010be570 100644 --- a/backend/airweave/domains/temporal/tests/test_temporal_schedule_service.py +++ b/backend/airweave/domains/temporal/tests/test_temporal_schedule_service.py @@ -12,7 +12,6 @@ from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException from temporalio.service import RPCError, RPCStatusCode from airweave.domains.temporal.exceptions import InvalidCronExpressionError @@ -628,7 +627,7 @@ async def test_delete_all_schedules_for_sync(case: DeleteAllCase): svc = _build_svc() call_log = [] - async def mock_delete(schedule_id, sync_id, db, ctx): + async def mock_delete(schedule_id, sync_id, db, ctx, *, uow=None): call_log.append(schedule_id) for prefix in case.delete_raises: if schedule_id.startswith(prefix): @@ -856,10 +855,7 @@ async def test_pause_schedules_pauses_all_prefixes(): f"minute-sync-{sync_id}", f"daily-cleanup-{sync_id}", } - actual_ids = { - call.args[0] - for call in mock_client.get_schedule_handle.call_args_list - } + actual_ids = {call.args[0] for call in mock_client.get_schedule_handle.call_args_list} assert actual_ids == expected_ids @@ -870,9 +866,7 @@ async def test_pause_schedules_swallows_not_found(): svc = _build_svc() handle = AsyncMock() - handle.pause.side_effect = _rpc_error( - "not found", RPCStatusCode.NOT_FOUND - ) + handle.pause.side_effect = _rpc_error("not found", RPCStatusCode.NOT_FOUND) mock_client = MagicMock() mock_client.get_schedule_handle.return_value = handle svc._get_client = AsyncMock(return_value=mock_client) @@ -903,9 +897,7 @@ async def test_unpause_schedules_swallows_not_found(): svc = _build_svc() handle = AsyncMock() - handle.unpause.side_effect = _rpc_error( - "not found", RPCStatusCode.NOT_FOUND - ) + handle.unpause.side_effect = _rpc_error("not found", RPCStatusCode.NOT_FOUND) mock_client = MagicMock() mock_client.get_schedule_handle.return_value = handle svc._get_client = AsyncMock(return_value=mock_client) @@ -953,14 +945,18 @@ async def test_get_schedules_for_sync_returns_metadata(): entries = [ _mock_list_entry( - f"minute-sync-{sync_id}", paused=True, + f"minute-sync-{sync_id}", + paused=True, note="Credential error: api_key_invalid", - next_times=[next_time], recent_count=3, + next_times=[next_time], + recent_count=3, ), _mock_list_entry( - f"daily-cleanup-{sync_id}", paused=True, + f"daily-cleanup-{sync_id}", + paused=True, note="Credential error: api_key_invalid", - next_times=[], recent_count=1, + next_times=[], + recent_count=1, ), ] @@ -997,3 +993,74 @@ async def test_get_schedules_for_sync_empty(): result = await svc.get_schedules_for_sync(uuid4()) assert result == [] + + +async def test_owned_cleanup_propagates_failure_and_keeps_caller_transaction(): + """A failed Temporal delete cannot acknowledge disconnect or release its DB lock.""" + svc = _build_svc() + failure = RuntimeError("Temporal unavailable") + svc._delete_schedule_by_id = AsyncMock(side_effect=failure) + db, uow, ctx = AsyncMock(), AsyncMock(), _mock_ctx() + with pytest.raises(RuntimeError, match="Temporal unavailable"): + await svc.delete_all_schedules_for_sync(SYNC_ID, db, ctx, uow=uow, strict=True) + assert svc._delete_schedule_by_id.call_args.kwargs["uow"] is uow + db.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_owned_reconcile_replaces_orphan_schedule_and_relinks_current_generation(): + """A lost create reply leaves remote state but no committed DB schedule link.""" + from types import SimpleNamespace + + from airweave.domains.temporal import schedule_ids + + sid = schedule_ids.sync_schedule_id(SYNC_ID) + remote = {sid: SimpleNamespace(action=SimpleNamespace(args=[{"provisioning_generation": 1}]))} + sync = _mock_sync_model(temporal_schedule_id=None) + repo = AsyncMock() + repo.get_without_connections.return_value = sync + + async def save(db, obj, values, ctx, **kwargs): + for key, value in values.items(): + setattr(obj, key, value) + + repo.update.side_effect = save + client = MagicMock() + + def handle(key): + async def delete(): + if remote.pop(key, None) is None: + raise _rpc_error("missing", RPCStatusCode.NOT_FOUND) + + async def describe(): + if key not in remote: + raise _rpc_error("missing", RPCStatusCode.NOT_FOUND) + return SimpleNamespace(schedule=remote[key]) + + return SimpleNamespace(delete=delete, describe=describe) + + async def create(key, schedule, **kwargs): + assert key not in remote + remote[key] = schedule + + client.get_schedule_handle.side_effect = handle + client.create_schedule = AsyncMock(side_effect=create) + svc = _build_svc(sync_repo=repo) + db, ctx, uow = AsyncMock(), _mock_ctx(), MagicMock() + with ( + patch.object(svc, "_get_client", new=AsyncMock(return_value=client)), + patch.object( + svc, + "_gather_schedule_data", + new=AsyncMock(return_value=({"provisioning_generation": 2}, {}, {})), + ), + ): + # These are the two real schedule-service calls made by owned reconciliation. + await svc.delete_all_schedules_for_sync(SYNC_ID, db, ctx, uow=uow, strict=True) + await svc.create_or_update_schedule(SYNC_ID, "0 0 * * *", db, ctx, uow) + assert list(remote) == [sid] + assert remote[sid].action.args[0]["provisioning_generation"] == 2 + assert sync.temporal_schedule_id == sid + assert sync.cron_schedule == "0 0 * * *" + assert all(call.kwargs["uow"] is uow for call in repo.update.call_args_list) + db.commit.assert_not_awaited() diff --git a/backend/airweave/domains/temporal/worker/__init__.py b/backend/airweave/domains/temporal/worker/__init__.py index a622a74416..861b8f0d46 100644 --- a/backend/airweave/domains/temporal/worker/__init__.py +++ b/backend/airweave/domains/temporal/worker/__init__.py @@ -10,7 +10,6 @@ import asyncio import signal from datetime import timedelta -from typing import Any from temporalio.worker import Worker @@ -63,12 +62,14 @@ def __init__(self, config: WorkerConfig) -> None: self._runtime = Runtime( telemetry=TelemetryConfig( metrics=PrometheusConfig( - bind_address=f"0.0.0.0:{config.sdk_metrics_port}", + bind_address=f"{config.bind_host}:{config.sdk_metrics_port}", ), ), ) self._worker: Worker | None = None self._state = WorkerState() + self._stop_task: asyncio.Task[None] | None = None + self._startup_lock = asyncio.Lock() registry = CollectorRegistry() self._control_server = WorkerControlServer( @@ -81,65 +82,79 @@ def __init__(self, config: WorkerConfig) -> None: async def start(self) -> None: """Start the worker and control server.""" - # Start control server first (non-blocking) - try: - await self._control_server.start() - except Exception as e: - logger.warning(f"Failed to start control server (metrics unavailable): {e}") - - from airweave.domains.temporal.client import get_client as get_temporal_client - - client = await get_temporal_client(runtime=self._runtime) - logger.info(f"Starting Temporal worker on task queue: {self._config.task_queue}") - - # Create worker - self._worker = Worker( - client, - task_queue=self._config.task_queue, - workflows=get_workflows(), - activities=create_activities(), - workflow_runner=self._get_sandbox_runner(), - max_concurrent_workflow_task_polls=self._config.max_concurrent_workflow_polls, - max_concurrent_activity_task_polls=self._config.max_concurrent_activity_polls, - sticky_queue_schedule_to_start_timeout=self._config.sticky_queue_schedule_to_start_timeout, - nonsticky_to_sticky_poll_ratio=self._config.nonsticky_to_sticky_poll_ratio, - default_heartbeat_throttle_interval=self._config.default_heartbeat_throttle_interval, - max_heartbeat_throttle_interval=self._config.max_heartbeat_throttle_interval, - graceful_shutdown_timeout=timedelta( - seconds=self._config.graceful_shutdown_timeout_seconds - ), - ) + async with self._startup_lock: + if self._stop_task is not None: + return + # Start control server first (non-blocking) + try: + await self._control_server.start() + except Exception as e: + logger.warning(f"Failed to start control server (metrics unavailable): {e}") + + if self._stop_task is not None: + return + + from airweave.domains.temporal.client import get_client as get_temporal_client + + client = await get_temporal_client(runtime=self._runtime) + if self._stop_task is not None: + return + logger.info(f"Starting Temporal worker on task queue: {self._config.task_queue}") + + # Create worker + self._worker = Worker( + client, + task_queue=self._config.task_queue, + workflows=get_workflows(), + activities=create_activities(), + workflow_runner=self._get_sandbox_runner(), + max_concurrent_workflow_task_polls=self._config.max_concurrent_workflow_polls, + max_concurrent_activity_task_polls=self._config.max_concurrent_activity_polls, + sticky_queue_schedule_to_start_timeout=self._config.sticky_queue_schedule_to_start_timeout, + nonsticky_to_sticky_poll_ratio=self._config.nonsticky_to_sticky_poll_ratio, + default_heartbeat_throttle_interval=self._config.default_heartbeat_throttle_interval, + max_heartbeat_throttle_interval=self._config.max_heartbeat_throttle_interval, + graceful_shutdown_timeout=timedelta( + seconds=self._config.graceful_shutdown_timeout_seconds + ), + ) - # Wire up drain callback - self._state.on_drain_requested = self._on_drain + # Wire up drain callback + self._state.on_drain_requested = self._on_drain - # Start worker (blocks until shutdown) - self._state.running = True - logger.info( - f"Worker started with graceful shutdown timeout: " - f"{self._config.graceful_shutdown_timeout_seconds}s" - ) + # Start worker (blocks until shutdown) + self._state.running = True + logger.info( + f"Worker started with graceful shutdown timeout: " + f"{self._config.graceful_shutdown_timeout_seconds}s" + ) await self._worker.run() async def stop(self) -> None: - """Stop the worker and control server.""" - if self._worker and self._state.running: - logger.info("Stopping worker gracefully") - self._state.running = False - await self._worker.shutdown() + """Wait for the single shutdown shared by signals, drain and main cleanup.""" + if self._stop_task is None: + self._stop_task = asyncio.create_task(self._stop()) + await asyncio.shield(self._stop_task) + + async def _stop(self) -> None: + """Shut down polling before releasing the control server and client.""" + async with self._startup_lock: + if self._worker and self._state.running: + logger.info("Stopping worker gracefully") + self._state.running = False + await self._worker.shutdown() - await self._control_server.stop() + await self._control_server.stop() - from airweave.domains.temporal.client import close as close_temporal_client + from airweave.domains.temporal.client import close as close_temporal_client - await close_temporal_client() + await close_temporal_client() async def _on_drain(self) -> None: """Called when drain is requested via control server.""" try: logger.info("Calling worker.shutdown() - stops polling for new work") - if self._worker: - await self._worker.shutdown() + await self.stop() logger.info("Worker shutdown complete - process will exit") except Exception as e: logger.error(f"Error during worker shutdown: {e}") @@ -173,25 +188,26 @@ async def main() -> None: initialize_container(settings) logger.info("Container initialized successfully") - # 2. Require OCR backend for the sync worker + # OCR is an optional document capability, not a worker startup dependency. if container_mod.container.ocr_provider is None: - logger.error( - "Temporal worker requires an OCR backend. " - "Set MISTRAL_API_KEY or DOCLING_BASE_URL and restart." + logger.warning( + "OCR is not configured. Text extraction and source capture remain available; " + "documents requiring OCR stay unindexed. Set MISTRAL_API_KEY or " + "DOCLING_BASE_URL to enable OCR and retry their projection." ) - raise SystemExit(1) # 3. Create worker with config config = WorkerConfig.from_settings() worker = TemporalWorker(config) # 5. Set up signal handlers - def signal_handler(signum: int, frame: Any) -> None: + def signal_handler(signum: int) -> None: logger.info(f"Received signal {signum}, shutting down...") asyncio.create_task(worker.stop()) - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) + loop = asyncio.get_running_loop() + for signum in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(signum, signal_handler, signum) # 6. Run worker try: @@ -199,7 +215,11 @@ def signal_handler(signum: int, frame: Any) -> None: except KeyboardInterrupt: logger.info("Received keyboard interrupt, shutting down...") finally: - await worker.stop() + try: + await worker.stop() + finally: + for signum in (signal.SIGINT, signal.SIGTERM): + loop.remove_signal_handler(signum) if __name__ == "__main__": diff --git a/backend/airweave/domains/temporal/worker/config.py b/backend/airweave/domains/temporal/worker/config.py index 4d0c30cd59..5304a2a022 100644 --- a/backend/airweave/domains/temporal/worker/config.py +++ b/backend/airweave/domains/temporal/worker/config.py @@ -12,6 +12,7 @@ class WorkerConfig: Attributes: task_queue: Temporal task queue name + bind_host: Bind address for control server and SDK metrics metrics_port: Port for control server (health, metrics, drain) graceful_shutdown_timeout_seconds: How long to wait for activities to complete @@ -31,6 +32,8 @@ class WorkerConfig: metrics_port: int graceful_shutdown_timeout_seconds: int + bind_host: str = "0.0.0.0" + # Polling concurrency max_concurrent_workflow_polls: int = 8 max_concurrent_activity_polls: int = 16 @@ -55,6 +58,7 @@ def from_settings(cls) -> "WorkerConfig": return cls( task_queue=settings.TEMPORAL_TASK_QUEUE, metrics_port=settings.WORKER_METRICS_PORT, + bind_host=settings.WORKER_BIND_HOST, graceful_shutdown_timeout_seconds=settings.TEMPORAL_GRACEFUL_SHUTDOWN_TIMEOUT, disable_sandbox=settings.TEMPORAL_DISABLE_SANDBOX, sdk_metrics_port=settings.TEMPORAL_SDK_METRICS_PORT, diff --git a/backend/airweave/domains/temporal/worker/control_server.py b/backend/airweave/domains/temporal/worker/control_server.py index 7b7349f406..a875457984 100644 --- a/backend/airweave/domains/temporal/worker/control_server.py +++ b/backend/airweave/domains/temporal/worker/control_server.py @@ -78,11 +78,11 @@ async def start(self) -> None: self._runner = web.AppRunner(app) await self._runner.setup() - site = web.TCPSite(self._runner, "0.0.0.0", self._config.metrics_port) + site = web.TCPSite(self._runner, self._config.bind_host, self._config.metrics_port) await site.start() logger.info( - f"Control server started on 0.0.0.0:{self._config.metrics_port} " + f"Control server started on {self._config.bind_host}:{self._config.metrics_port} " f"(endpoints: /health, /metrics, /status, /drain)" ) diff --git a/backend/airweave/domains/temporal/worker/tests/test_config.py b/backend/airweave/domains/temporal/worker/tests/test_config.py index ae13090cc3..335077f392 100644 --- a/backend/airweave/domains/temporal/worker/tests/test_config.py +++ b/backend/airweave/domains/temporal/worker/tests/test_config.py @@ -14,6 +14,7 @@ def test_sdk_metrics_port_default(): graceful_shutdown_timeout_seconds=30, ) assert config.sdk_metrics_port == 9090 + assert config.bind_host == "0.0.0.0" def test_sdk_metrics_port_override(): @@ -32,6 +33,7 @@ def test_from_settings_wires_sdk_metrics_port(): with patch("airweave.domains.temporal.worker.config.settings") as mock: mock.TEMPORAL_TASK_QUEUE = "q" mock.WORKER_METRICS_PORT = 8080 + mock.WORKER_BIND_HOST = "0.0.0.0" mock.TEMPORAL_GRACEFUL_SHUTDOWN_TIMEOUT = 30 mock.TEMPORAL_DISABLE_SANDBOX = False mock.TEMPORAL_SDK_METRICS_PORT = 7777 @@ -46,6 +48,7 @@ def test_from_settings_wires_all_fields(): with patch("airweave.domains.temporal.worker.config.settings") as mock: mock.TEMPORAL_TASK_QUEUE = "my-queue" mock.WORKER_METRICS_PORT = 9091 + mock.WORKER_BIND_HOST = "127.0.0.1" mock.TEMPORAL_GRACEFUL_SHUTDOWN_TIMEOUT = 60 mock.TEMPORAL_DISABLE_SANDBOX = True mock.TEMPORAL_SDK_METRICS_PORT = 9999 @@ -54,6 +57,7 @@ def test_from_settings_wires_all_fields(): assert config.task_queue == "my-queue" assert config.metrics_port == 9091 + assert config.bind_host == "127.0.0.1" assert config.graceful_shutdown_timeout_seconds == 60 assert config.disable_sandbox is True assert config.sdk_metrics_port == 9999 diff --git a/backend/airweave/domains/temporal/worker/tests/test_wiring.py b/backend/airweave/domains/temporal/worker/tests/test_wiring.py index 13a59fc1d4..5948f2e0a9 100644 --- a/backend/airweave/domains/temporal/worker/tests/test_wiring.py +++ b/backend/airweave/domains/temporal/worker/tests/test_wiring.py @@ -32,7 +32,17 @@ def test_create_activities_returns_list(): result = create_activities() assert isinstance(result, list) - assert len(result) == 7 + assert len(result) == 10 + activity_classes = {type(method.__self__).__name__ for method in result} + assert "ProjectCanonicalRecordsActivity" in activity_classes + assert "CleanupProjectionGenerationsActivity" in activity_classes + assert "DiscoverNativeProjectionActivity" in activity_classes + discovery = next( + method.__self__ + for method in result + if type(method.__self__).__name__ == "DiscoverNativeProjectionActivity" + ) + assert discovery.source_registry is mock_container.source_registry @pytest.mark.unit @@ -51,8 +61,10 @@ def test_get_workflows_returns_classes(): result = get_workflows() assert isinstance(result, list) - assert len(result) == 4 + assert len(result) == 6 class_names = [cls.__name__ for cls in result] + assert "ProjectCanonicalRecordsWorkflow" in class_names + assert "RecoverNativeProjectionWorkflow" in class_names assert "RunSourceConnectionWorkflow" in class_names assert "CleanupStuckSyncJobsWorkflow" in class_names assert "CleanupSyncDataWorkflow" in class_names diff --git a/backend/airweave/domains/temporal/worker/tests/test_worker.py b/backend/airweave/domains/temporal/worker/tests/test_worker.py index 339edf7b87..c690a3ec9e 100644 --- a/backend/airweave/domains/temporal/worker/tests/test_worker.py +++ b/backend/airweave/domains/temporal/worker/tests/test_worker.py @@ -5,9 +5,11 @@ WorkerMetricsRegistry) is constructed for real so tests exercise actual wiring. """ +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest +from aiohttp import ClientSession from temporalio.runtime import PrometheusConfig, TelemetryConfig from airweave.domains.temporal.worker.config import WorkerConfig @@ -16,11 +18,11 @@ def _make_config(**overrides): """Build a minimal WorkerConfig for testing.""" - defaults = dict( - task_queue="test-queue", - metrics_port=8080, - graceful_shutdown_timeout_seconds=30, - ) + defaults = { + "task_queue": "test-queue", + "metrics_port": 8080, + "graceful_shutdown_timeout_seconds": 30, + } defaults.update(overrides) return WorkerConfig(**defaults) @@ -33,7 +35,7 @@ def test_init_creates_runtime_with_correct_config(mock_runtime_cls): """__init__ builds a real TelemetryConfig(PrometheusConfig) and passes it to Runtime.""" from airweave.domains.temporal.worker import TemporalWorker - config = _make_config(sdk_metrics_port=9999) + config = _make_config(sdk_metrics_port=9999, bind_host="127.0.0.1") worker = TemporalWorker(config) # Runtime was called exactly once @@ -46,7 +48,7 @@ def test_init_creates_runtime_with_correct_config(mock_runtime_cls): # Its metrics member is a real PrometheusConfig with the right address assert isinstance(telemetry.metrics, PrometheusConfig) - assert telemetry.metrics.bind_address == "0.0.0.0:9999" + assert telemetry.metrics.bind_address == "127.0.0.1:9999" # The worker holds the Runtime *instance* assert worker._runtime is mock_runtime_cls.return_value @@ -183,3 +185,160 @@ async def test_stop_skips_shutdown_when_not_running(mock_runtime_cls, mock_clien worker._control_server.stop.assert_awaited_once() mock_client_close.assert_awaited_once() + + +@patch("temporalio.runtime.Runtime") +async def test_control_server_binds_only_loopback(mock_runtime_cls): + """Real aiohttp listener uses the same configured host as SDK metrics.""" + from airweave.domains.temporal.worker import TemporalWorker + + worker = TemporalWorker(_make_config(bind_host="127.0.0.1", metrics_port=0)) + server = worker._control_server + try: + await server.start() + assert server._runner is not None + addresses = server._runner.addresses + assert len(addresses) == 1 + host, port = addresses[0] + assert host == "127.0.0.1" + async with ClientSession() as client: + async with client.get(f"http://127.0.0.1:{port}/health") as response: + assert response.status == 503 + assert await response.text() == "NOT_RUNNING" + finally: + await server.stop() + + +@patch("airweave.domains.temporal.client.close", new_callable=AsyncMock) +@patch("temporalio.runtime.Runtime") +async def test_concurrent_stop_waits_for_one_complete_shutdown(mock_runtime_cls, mock_client_close): + """Signal and main-finally callers share SDK shutdown and subsequent cleanup.""" + from airweave.domains.temporal.worker import TemporalWorker + + worker = TemporalWorker(_make_config()) + entered = asyncio.Event() + release = asyncio.Event() + + async def shutdown(): + entered.set() + await release.wait() + + worker._worker = AsyncMock() + worker._worker.shutdown.side_effect = shutdown + worker._state.running = True + worker._control_server.stop = AsyncMock() + first = asyncio.create_task(worker.stop()) + await entered.wait() + second = asyncio.create_task(worker.stop()) + try: + await asyncio.sleep(0) + assert not second.done() + worker._control_server.stop.assert_not_awaited() + mock_client_close.assert_not_awaited() + finally: + release.set() + await asyncio.gather(first, second) + await worker.stop() + worker._worker.shutdown.assert_awaited_once() + worker._control_server.stop.assert_awaited_once() + mock_client_close.assert_awaited_once() + + +@patch("airweave.domains.temporal.client.close", new_callable=AsyncMock) +@patch("temporalio.runtime.Runtime") +async def test_cancelled_stop_waiter_does_not_cancel_shutdown(mock_runtime_cls, mock_client_close): + """Cancellation of one caller leaves shutdown owned by the shared task.""" + from airweave.domains.temporal.worker import TemporalWorker + + worker = TemporalWorker(_make_config()) + entered = asyncio.Event() + release = asyncio.Event() + + async def shutdown(): + entered.set() + await release.wait() + + worker._worker = AsyncMock() + worker._worker.shutdown.side_effect = shutdown + worker._state.running = True + worker._control_server.stop = AsyncMock() + caller = asyncio.create_task(worker.stop()) + await entered.wait() + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + release.set() + await worker.stop() + worker._worker.shutdown.assert_awaited_once() + worker._control_server.stop.assert_awaited_once() + mock_client_close.assert_awaited_once() + + +@patch("temporalio.runtime.Runtime") +async def test_stop_failure_remains_observable_to_later_callers(mock_runtime_cls): + """A failed shutdown cannot become an apparent successful repeated stop.""" + from airweave.domains.temporal.worker import TemporalWorker + + worker = TemporalWorker(_make_config()) + worker._worker = AsyncMock() + worker._worker.shutdown.side_effect = RuntimeError("shutdown failed") + worker._state.running = True + worker._control_server.stop = AsyncMock() + for _ in range(2): + with pytest.raises(RuntimeError, match="shutdown failed"): + await worker.stop() + worker._worker.shutdown.assert_awaited_once() + worker._control_server.stop.assert_not_awaited() + + +@patch("airweave.domains.temporal.worker.create_activities", return_value=[]) +@patch("airweave.domains.temporal.worker.get_workflows", return_value=[]) +@pytest.mark.parametrize("blocked_phase", ["control", "client"]) +@patch("airweave.domains.temporal.client.close", new_callable=AsyncMock) +@patch("airweave.domains.temporal.worker.Worker") +@patch("temporalio.runtime.Runtime") +async def test_stop_during_startup_waits_then_prevents_polling( + mock_runtime_cls, + mock_worker_cls, + mock_client_close, + mock_workflows, + mock_activities, + blocked_phase, +): + """Cleanup waits for resource creation and no SDK worker starts after stop.""" + from airweave.domains.temporal.worker import TemporalWorker + + worker = TemporalWorker(_make_config()) + entered = asyncio.Event() + release = asyncio.Event() + + async def blocked(*args, **kwargs): + entered.set() + await release.wait() + return MagicMock() + + worker._control_server.start = AsyncMock( + side_effect=blocked if blocked_phase == "control" else None + ) + worker._control_server.stop = AsyncMock() + mock_worker_cls.return_value.run = AsyncMock() + with patch("airweave.domains.temporal.client.get_client", new_callable=AsyncMock) as client: + if blocked_phase == "client": + client.side_effect = blocked + starting = asyncio.create_task(worker.start()) + await entered.wait() + stopping = asyncio.create_task(worker.stop()) + try: + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not stopping.done() + worker._control_server.stop.assert_not_awaited() + mock_client_close.assert_not_awaited() + finally: + release.set() + await asyncio.gather(starting, stopping) + mock_worker_cls.assert_not_called() + if blocked_phase == "control": + client.assert_not_awaited() + worker._control_server.stop.assert_awaited_once() + mock_client_close.assert_awaited_once() diff --git a/backend/airweave/domains/temporal/worker/tests/test_worker_ocr_guard.py b/backend/airweave/domains/temporal/worker/tests/test_worker_ocr_guard.py index d9caf2f067..3bd7406eb7 100644 --- a/backend/airweave/domains/temporal/worker/tests/test_worker_ocr_guard.py +++ b/backend/airweave/domains/temporal/worker/tests/test_worker_ocr_guard.py @@ -1,58 +1,79 @@ -"""Tests for the OCR guard in the Temporal worker main().""" +"""OCR capability does not prevent the real worker composition from starting.""" -from unittest.mock import patch +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import httpx import pytest import airweave.core.container as container_mod +from airweave.core.config import settings +from airweave.core.container import factory +from airweave.domains.embedders import config as embedding_config +from airweave.domains.temporal import worker @pytest.mark.asyncio -async def test_raises_system_exit_when_ocr_is_none(test_container): - """Worker must abort when the container has no OCR provider.""" - no_ocr = test_container.replace(ocr_provider=None) - saved = container_mod.container +@pytest.mark.parametrize("health_status", [200, 503]) +async def test_worker_composes_without_ocr_and_starts_supported_activities( + monkeypatch, tmp_path, health_status +): + """Real DI composition; substitute Temporal lifecycle and inference health HTTP.""" + configured = settings.model_copy( + update={ + "MISTRAL_API_KEY": "", + "DOCLING_BASE_URL": "", + "STORAGE_BACKEND": "filesystem", + "STORAGE_PATH": str(tmp_path), + "STRIPE_ENABLED": False, + "ANALYTICS_ENABLED": False, + } + ) + monkeypatch.setattr(worker, "settings", configured) + monkeypatch.setattr(factory, "DENSE_EMBEDDER", "local_minilm") + monkeypatch.setattr(factory, "EMBEDDING_DIMENSIONS", 384) + monkeypatch.setattr(embedding_config, "DENSE_EMBEDDER", "local_minilm") + monkeypatch.setattr(embedding_config, "EMBEDDING_DIMENSIONS", 384) + # Stub only the external inference health HTTP call, not embedding validation. + original_client = httpx.Client - try: - container_mod.container = no_ocr - with patch.object( - container_mod, - "initialize_container", - ): - from airweave.domains.temporal.worker import main - - with pytest.raises(SystemExit) as exc_info: - await main() - - assert exc_info.value.code == 1 - finally: - container_mod.container = saved - - -@pytest.mark.asyncio -async def test_passes_guard_when_ocr_present(test_container): - """Worker proceeds past OCR guard when provider is present.""" - saved = container_mod.container - - try: - container_mod.container = test_container - with ( - patch.object( - container_mod, - "initialize_container", + def health_client(*args, **kwargs): + return original_client( + transport=httpx.MockTransport( + lambda request: httpx.Response(health_status) + if request.url.path == "/.well-known/ready" + else httpx.Response(503) ), - patch( - "airweave.domains.temporal.worker.WorkerConfig.from_settings", - side_effect=SystemExit(99), - ), - ): - from airweave.domains.temporal.worker import main - - with pytest.raises(SystemExit) as exc_info: - await main() + **kwargs, + ) - # Exit code 99 means we passed the OCR guard and hit - # the WorkerConfig stub — the guard did not fire. - assert exc_info.value.code == 99 - finally: - container_mod.container = saved + monkeypatch.setattr( + embedding_config, + "httpx", + SimpleNamespace( + Client=health_client, + Timeout=httpx.Timeout, + ConnectError=httpx.ConnectError, + TimeoutException=httpx.TimeoutException, + HTTPStatusError=httpx.HTTPStatusError, + ), + ) + monkeypatch.setattr(container_mod, "container", None) + lifecycle = MagicMock(start=AsyncMock(), stop=AsyncMock()) + monkeypatch.setattr(worker, "TemporalWorker", lambda _: lifecycle) + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "add_signal_handler", lambda *_: None) + monkeypatch.setattr(loop, "remove_signal_handler", lambda *_: True) + if health_status != 200: + with pytest.raises(embedding_config.EmbeddingConfigError, match="not reachable"): + await worker.main() + lifecycle.start.assert_not_awaited() + return + await worker.main() + assert container_mod.container.ocr_provider is None + assert container_mod.container.converter_registry.for_extension(".txt") is not None + assert container_mod.container.converter_registry.for_extension(".pdf") is not None + assert container_mod.container.converter_registry.for_extension(".png") is None + lifecycle.start.assert_awaited_once() + lifecycle.stop.assert_awaited_once() diff --git a/backend/airweave/domains/temporal/worker/wiring.py b/backend/airweave/domains/temporal/worker/wiring.py index 58defe7211..f6da596670 100644 --- a/backend/airweave/domains/temporal/worker/wiring.py +++ b/backend/airweave/domains/temporal/worker/wiring.py @@ -33,6 +33,20 @@ def create_activities() -> list: if container is None: raise RuntimeError("Container not initialized — cannot wire activities") + from airweave.db.session import get_db_context + from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore + from airweave.domains.entities.canonical.projector import CanonicalProjector + from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor + from airweave.domains.temporal.activities.cleanup_projection_generations import ( + CleanupProjectionGenerationsActivity, + ) + from airweave.domains.temporal.activities.discover_native_projection import ( + DiscoverNativeProjectionActivity, + ) + from airweave.domains.temporal.activities.project_canonical_records import ( + ProjectCanonicalRecordsActivity, + ) + email_service = container.email_service event_bus = container.event_bus sync_service = container.sync_service @@ -50,6 +64,21 @@ def create_activities() -> list: logger.debug("Wiring activities with container dependencies") return [ + DiscoverNativeProjectionActivity(container.source_registry).run, + CleanupProjectionGenerationsActivity(container.storage_backend).run, + ProjectCanonicalRecordsActivity( + projector=CanonicalProjector( + CanonicalProjectionStore(), + get_db_context, + ChunkEmbedProcessor( + container.converter_registry, + container.dense_embedder, + container.sparse_embedder, + ), + container.storage_backend, + ), + source_registry=container.source_registry, + ).run, RunSyncActivity( sync_service=sync_service, sync_repo=sync_repo, @@ -101,8 +130,16 @@ def get_workflows() -> list: CleanupSyncDataWorkflow, RunSourceConnectionWorkflow, ) + from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, + ) + from airweave.domains.temporal.workflows.recover_native_projection import ( + RecoverNativeProjectionWorkflow, + ) return [ + RecoverNativeProjectionWorkflow, + ProjectCanonicalRecordsWorkflow, RunSourceConnectionWorkflow, CleanupStuckSyncJobsWorkflow, CleanupSyncDataWorkflow, diff --git a/backend/airweave/domains/temporal/workflows/cleanup_stuck_sync_jobs.py b/backend/airweave/domains/temporal/workflows/cleanup_stuck_sync_jobs.py index 3896f18511..14f9cebd65 100644 --- a/backend/airweave/domains/temporal/workflows/cleanup_stuck_sync_jobs.py +++ b/backend/airweave/domains/temporal/workflows/cleanup_stuck_sync_jobs.py @@ -5,10 +5,17 @@ from datetime import timedelta from temporalio import workflow -from temporalio.common import RetryPolicy +from temporalio.common import RetryPolicy, WorkflowIDReusePolicy +from temporalio.exceptions import WorkflowAlreadyStartedError with workflow.unsafe.imports_passed_through(): from airweave.domains.temporal.activities import cleanup_stuck_sync_jobs_activity + from airweave.domains.temporal.activities.cleanup_projection_generations import ( + CleanupProjectionGenerationsActivity, + ) + from airweave.domains.temporal.workflows.recover_native_projection import ( + RecoverNativeProjectionWorkflow, + ) _CLEANUP_TIMEOUT = timedelta(minutes=5) _CLEANUP_RETRY = RetryPolicy( @@ -29,9 +36,27 @@ class CleanupStuckSyncJobsWorkflow: @workflow.run async def run(self) -> None: - """Run the cleanup workflow.""" - await workflow.execute_activity( - cleanup_stuck_sync_jobs_activity, # type: ignore[arg-type] - start_to_close_timeout=_CLEANUP_TIMEOUT, - retry_policy=_CLEANUP_RETRY, - ) + """Preserve cleanup failures while independently dispatching native recovery.""" + try: + await workflow.execute_activity( + cleanup_stuck_sync_jobs_activity, # type: ignore[arg-type] + start_to_close_timeout=_CLEANUP_TIMEOUT, + retry_policy=_CLEANUP_RETRY, + ) + if workflow.patched("canonical-generation-gc-v1"): + await workflow.execute_activity( + CleanupProjectionGenerationsActivity.run, + start_to_close_timeout=_CLEANUP_TIMEOUT, + retry_policy=_CLEANUP_RETRY, + ) + finally: + if workflow.patched("native-projection-recovery-v1"): + try: + await workflow.start_child_workflow( + RecoverNativeProjectionWorkflow.run, + id="native-projection-recovery", + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + except WorkflowAlreadyStartedError: + pass diff --git a/backend/airweave/domains/temporal/workflows/project_canonical_records.py b/backend/airweave/domains/temporal/workflows/project_canonical_records.py new file mode 100644 index 0000000000..84371d9d09 --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/project_canonical_records.py @@ -0,0 +1,57 @@ +"""Independent retryable projection on the existing Temporal worker/task queue.""" + +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from airweave.domains.temporal.activities.project_canonical_records import ( + ProjectCanonicalRecordsActivity, + ) + + +@workflow.defn +class ProjectCanonicalRecordsWorkflow: + """Scan pending rows; publication failures never change capture-job status.""" + + @workflow.run + async def run( + self, + organization_id: str, + sync_id: str, + after_id: str | None = None, + failures: int = 0, + sweep: int = 0, + skip_failed: bool = False, + ) -> None: + """Drain bounded pages; retry failed sweeps without recapturing providers.""" + for _ in range(100): + result = await workflow.execute_activity( + ProjectCanonicalRecordsActivity.run, + args=( + [organization_id, sync_id, after_id, True] + if skip_failed + else [organization_id, sync_id, after_id] + ), + start_to_close_timeout=timedelta(minutes=30), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + failures += result["failed"] + after_id = result["after_id"] + if not result["has_more"]: + if not failures: + return + if skip_failed or sweep >= 2: + raise ApplicationError( + "Canonical projection still has failed records; retained as pending", + non_retryable=True, + ) + await workflow.sleep(timedelta(seconds=30 * (2**sweep))) + workflow.continue_as_new(args=[organization_id, sync_id, None, 0, sweep + 1]) + # Bound history even for very large sources. + args = [organization_id, sync_id, after_id, failures, sweep] + if skip_failed: + args.append(True) + workflow.continue_as_new(args=args) diff --git a/backend/airweave/domains/temporal/workflows/recover_native_projection.py b/backend/airweave/domains/temporal/workflows/recover_native_projection.py new file mode 100644 index 0000000000..f637ce431a --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/recover_native_projection.py @@ -0,0 +1,58 @@ +"""Recover canonical publications independently of capture and cleanup outcomes.""" + +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy, WorkflowIDReusePolicy +from temporalio.exceptions import WorkflowAlreadyStartedError + +with workflow.unsafe.imports_passed_through(): + from airweave.domains.entities.canonical.projection_models import ( + ProjectionSourcePage, + canonical_projection_workflow_id, + ) + from airweave.domains.temporal.activities.discover_native_projection import ( + DiscoverNativeProjectionActivity, + ) + from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, + ) + + +@workflow.defn +class RecoverNativeProjectionWorkflow: + """Page through durable pending records; never recapture or retry known failures.""" + + @workflow.run + async def run(self, after_sync_id: str | None = None) -> None: + """Advance every source page independently of its child projection outcome.""" + # Keyset progress lives in workflow history, not another checkpoint table. + # A bad/active source cannot keep every later source off the first page. + for _ in range(100): + page = ProjectionSourcePage.model_validate( + await workflow.execute_activity( + DiscoverNativeProjectionActivity.run, + after_sync_id, + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + ) + for source in page.sources: + try: + await workflow.start_child_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[str(source.organization_id), str(source.sync_id), None, 0, 0, True], + id=( + canonical_projection_workflow_id(source.organization_id, source.sync_id) + if workflow.patched("canonical-projection-identity-v1") + else f"native-projection:{source.organization_id}:{source.sync_id}" + ), + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + except WorkflowAlreadyStartedError: + pass # An existing run owns this source; keep walking other pages. + if page.next_cursor is None: + return + after_sync_id = str(page.next_cursor) + workflow.continue_as_new(args=[after_sync_id]) diff --git a/backend/airweave/domains/temporal/workflows/run_source_connection.py b/backend/airweave/domains/temporal/workflows/run_source_connection.py index f2966a83bc..8e822fb736 100644 --- a/backend/airweave/domains/temporal/workflows/run_source_connection.py +++ b/backend/airweave/domains/temporal/workflows/run_source_connection.py @@ -12,14 +12,18 @@ from typing import Any, Dict, Optional from temporalio import workflow -from temporalio.common import RetryPolicy +from temporalio.common import RetryPolicy, WorkflowIDReusePolicy from temporalio.exceptions import ( ActivityError, ApplicationError, + WorkflowAlreadyStartedError, is_cancelled_exception, ) with workflow.unsafe.imports_passed_through(): + from airweave.domains.entities.canonical.projection_models import ( + canonical_projection_workflow_id, + ) from airweave.domains.temporal.activities import ( create_sync_job_activity, run_sync_activity, @@ -31,6 +35,9 @@ CLASSIFIED_USER_ERROR_TYPE, ORPHANED_SYNC_ERROR_TYPE, ) + from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, + ) # --------------------------------------------------------------------------- # Execution policies @@ -189,22 +196,50 @@ async def _execute_sync( local_development = ctx_dict.get("local_development", False) heartbeat_timeout = _HEARTBEAT_TIMEOUT_LOCAL if local_development else _HEARTBEAT_TIMEOUT - await workflow.execute_activity( - run_sync_activity, - args=[ - sync_dict, - sync_job_dict, - collection_dict, - connection_dict, - ctx_dict, - access_token, - force_full_sync, - ], - start_to_close_timeout=_SYNC_TIMEOUT, - heartbeat_timeout=heartbeat_timeout, - cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, - retry_policy=_NO_RETRY, - ) + try: + await workflow.execute_activity( + run_sync_activity, + args=[ + sync_dict, + sync_job_dict, + collection_dict, + connection_dict, + ctx_dict, + access_token, + force_full_sync, + ], + start_to_close_timeout=_SYNC_TIMEOUT, + heartbeat_timeout=heartbeat_timeout, + cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + retry_policy=_NO_RETRY, + ) + finally: + # Starting the child is durable workflow history. Capture may be partial; + # projection consumes only committed rows and owns its own retries/status. + organization_id = str(ctx_dict["organization"]["id"]) + sync_id = str(sync_dict["id"]) + if workflow.patched("canonical-projection-identity-v1"): + try: + await asyncio.shield( + workflow.start_child_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[organization_id, sync_id, None, 0, 0, True], + id=canonical_projection_workflow_id(organization_id, sync_id), + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + ) + except WorkflowAlreadyStartedError: + pass # Maintenance or an explicit operator run already owns projection. + else: + await asyncio.shield( + workflow.start_child_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[organization_id, sync_id], + id=f"projection:{sync_dict['id']}:{workflow.info().run_id}", + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + ) # ------------------------------------------------------------------ # Phase 3: State transitions diff --git a/backend/airweave/domains/temporal/workflows/tests/test_canonical_projection.py b/backend/airweave/domains/temporal/workflows/tests/test_canonical_projection.py new file mode 100644 index 0000000000..f805dcc275 --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/tests/test_canonical_projection.py @@ -0,0 +1,42 @@ +"""Real Temporal test-server proof of independent bounded projection retries.""" + +from uuid import uuid4 + +from temporalio import activity +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, +) + + +async def test_projection_retries_failed_sweep_without_provider_capture(): + calls = [] + + @activity.defn(name="project_canonical_records_activity") + async def project(organization: str, sync: str, after: str | None = None) -> dict: + calls.append((organization, sync, after)) + return { + "after_id": None, + "has_more": False, + "failed": int(len(calls) == 1), + "published": int(len(calls) > 1), + "superseded": 0, + } + + async with await WorkflowEnvironment.start_time_skipping() as env: + async with Worker( + env.client, + task_queue="canonical-projection-test", + workflows=[ProjectCanonicalRecordsWorkflow], + activities=[project], + ): + await env.client.execute_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[str(uuid4()), str(uuid4())], + id=str(uuid4()), + task_queue="canonical-projection-test", + ) + assert len(calls) == 2 + assert calls[0] == calls[1] diff --git a/backend/airweave/domains/temporal/workflows/tests/test_cleanup_workflows.py b/backend/airweave/domains/temporal/workflows/tests/test_cleanup_workflows.py index 31ade8b619..66f3447945 100644 --- a/backend/airweave/domains/temporal/workflows/tests/test_cleanup_workflows.py +++ b/backend/airweave/domains/temporal/workflows/tests/test_cleanup_workflows.py @@ -3,6 +3,7 @@ import uuid import pytest +from temporalio import activity from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker @@ -12,6 +13,9 @@ from airweave.domains.temporal.workflows.cleanup_sync_data import ( CleanupSyncDataWorkflow, ) +from airweave.domains.temporal.workflows.recover_native_projection import ( + RecoverNativeProjectionWorkflow, +) from .conftest import ( COLLECTION_ID, @@ -27,20 +31,32 @@ @pytest.mark.unit async def test_cleanup_stuck_sync_jobs_calls_activity(): + @activity.defn(name="cleanup_projection_generations_activity") + async def cleanup_generations(): + recorder.record("cleanup_generations", ()) + + @activity.defn(name="discover_native_projection_activity") + async def discover_native(after: str | None = None): + return {"sources": [], "next_cursor": None} + recorder = ActivityRecorder() async with await WorkflowEnvironment.start_time_skipping() as env: async with Worker( env.client, task_queue=TASK_QUEUE, - workflows=[CleanupStuckSyncJobsWorkflow], - activities=[mock_cleanup_stuck_sync_jobs(recorder)], + workflows=[CleanupStuckSyncJobsWorkflow, RecoverNativeProjectionWorkflow], + activities=[ + mock_cleanup_stuck_sync_jobs(recorder), cleanup_generations, discover_native + ], ): await env.client.execute_workflow( CleanupStuckSyncJobsWorkflow.run, id=f"test-cleanup-stuck-{uuid.uuid4()}", task_queue=TASK_QUEUE, ) + await env.client.get_workflow_handle("native-projection-recovery").result() + assert recorder.called("cleanup_generations") assert recorder.called("cleanup_stuck") assert recorder.call_count("cleanup_stuck") == 1 diff --git a/backend/airweave/domains/temporal/workflows/tests/test_native_projection_recovery.py b/backend/airweave/domains/temporal/workflows/tests/test_native_projection_recovery.py new file mode 100644 index 0000000000..f621d831b8 --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/tests/test_native_projection_recovery.py @@ -0,0 +1,308 @@ +"""Real Temporal child deduplication, page progress and bounded failure recovery.""" + +import asyncio +from datetime import timedelta +from uuid import UUID, uuid4 + +import pytest +from temporalio import activity, workflow +from temporalio.client import WorkflowFailureError +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Replayer, Worker + +from airweave.domains.temporal.workflows.cleanup_stuck_sync_jobs import CleanupStuckSyncJobsWorkflow +from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, +) +from airweave.domains.temporal.workflows.recover_native_projection import ( + RecoverNativeProjectionWorkflow, +) + + +@workflow.defn(name="CleanupStuckSyncJobsWorkflow") +class PreviousCleanupWorkflow: + """Previous command sequence, used to generate real pre-recovery history.""" + + @workflow.run + async def run(self) -> None: + retry = RetryPolicy( + maximum_attempts=3, + initial_interval=timedelta(seconds=10), + maximum_interval=timedelta(seconds=60), + ) + await workflow.execute_activity( + "cleanup_stuck_sync_jobs_activity", + start_to_close_timeout=timedelta(minutes=5), + retry_policy=retry, + ) + if workflow.patched("canonical-generation-gc-v1"): + await workflow.execute_activity( + "cleanup_projection_generations_activity", + start_to_close_timeout=timedelta(minutes=5), + retry_policy=retry, + ) + + +async def test_previous_maintenance_history_replays_without_recovery_commands(): + @activity.defn(name="cleanup_stuck_sync_jobs_activity") + async def cleanup(): + pass + + @activity.defn(name="cleanup_projection_generations_activity") + async def generations(): + pass + + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "native-replay-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[PreviousCleanupWorkflow], + activities=[cleanup, generations], + ): + handle = await env.client.start_workflow( + PreviousCleanupWorkflow.run, id=uuid4().hex, task_queue=queue + ) + await handle.result() + history = await handle.fetch_history() + await Replayer(workflows=[CleanupStuckSyncJobsWorkflow]).replay_workflow(history) + + +async def test_recovery_walks_later_page_despite_active_source_and_cleanup_failure(): + organization = str(uuid4()) + sources = [str(UUID(int=i)) for i in range(1, 22)] + calls, cursors = [], [] + started, release = asyncio.Event(), asyncio.Event() + + @activity.defn(name="project_canonical_records_activity") + async def project(org: str, sync: str, after: str | None = None, skip_failed: bool = False): + assert org == organization and skip_failed + calls.append(sync) + if sync == sources[0]: + started.set() + await release.wait() + return {"after_id": None, "has_more": False, "failed": 0, "published": 1} + + @activity.defn(name="discover_native_projection_activity") + async def discover(after: str | None = None): + cursors.append(after) + page = sources[:20] if after is None else sources[20:] + return { + "sources": [{"organization_id": organization, "sync_id": sync} for sync in page], + "next_cursor": sources[19] if after is None else None, + } + + @activity.defn(name="cleanup_stuck_sync_jobs_activity") + async def cleanup(): + raise ApplicationError("Unrelated provider cleanup failed", non_retryable=True) + + @activity.defn(name="cleanup_projection_generations_activity") + async def generations(): + pass + + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "native-recovery-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[ + CleanupStuckSyncJobsWorkflow, + RecoverNativeProjectionWorkflow, + ProjectCanonicalRecordsWorkflow, + ], + activities=[project, discover, cleanup, generations], + ): + active = await env.client.start_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[organization, sources[0], None, 0, 0, True], + id=f"canonical-projection:{organization}:{sources[0]}", + task_queue=queue, + ) + await asyncio.wait_for(started.wait(), timeout=20) + try: + with pytest.raises(WorkflowFailureError): + await env.client.execute_workflow( + CleanupStuckSyncJobsWorkflow.run, id=uuid4().hex, task_queue=queue + ) + await env.client.get_workflow_handle("native-projection-recovery").result() + release.set() + await active.result() + for sync in sources[1:]: + await env.client.get_workflow_handle( + f"canonical-projection:{organization}:{sync}" + ).result() + finally: + release.set() + assert cursors == [None, sources[19]] + assert sorted(calls) == sorted(sources) + + +async def test_automatic_recovery_reports_failure_without_restarting_failed_sweeps(): + calls = [] + + @activity.defn(name="project_canonical_records_activity") + async def project(org: str, sync: str, after: str | None = None, skip_failed: bool = False): + calls.append(skip_failed) + return {"after_id": None, "has_more": False, "failed": 1, "published": 0} + + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "native-failure-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[ProjectCanonicalRecordsWorkflow], + activities=[project], + ): + with pytest.raises(WorkflowFailureError): + await env.client.execute_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[str(uuid4()), str(uuid4()), None, 0, 0, True], + id=uuid4().hex, + task_queue=queue, + ) + assert calls == [True] + + +async def test_maintenance_projects_during_capture_and_finalizer_reuses_active_projection(): + """Capture need not finish for search publication, and both triggers share ownership.""" + from airweave.domains.temporal.workflows.run_source_connection import ( + RunSourceConnectionWorkflow, + ) + + from .conftest import ( + ORG_ID, + SYNC_ID, + ActivityRecorder, + make_collection_dict, + make_connection_dict, + make_ctx_dict, + make_sync_dict, + make_sync_job_dict, + mock_transition_sync_job, + ) + + capturing, projected = asyncio.Event(), asyncio.Event() + finish_capture, finish_projection = asyncio.Event(), asyncio.Event() + calls = [] + + @activity.defn(name="run_sync_activity") + async def capture( + sync: dict, + job: dict, + collection: dict, + connection: dict, + context: dict, + token: str | None, + force: bool, + ): + capturing.set() + await finish_capture.wait() + + @activity.defn(name="discover_native_projection_activity") + async def discover(after: str | None = None): + assert capturing.is_set() and not finish_capture.is_set() + return {"sources": [{"organization_id": ORG_ID, "sync_id": SYNC_ID}], "next_cursor": None} + + @activity.defn(name="project_canonical_records_activity") + async def project(org: str, sync: str, after: str | None = None, skip_failed: bool = False): + assert skip_failed + calls.append(sync) + projected.set() + await finish_projection.wait() + return {"after_id": None, "has_more": False, "failed": 0, "published": 1} + + recorder = ActivityRecorder() + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "concurrent-projection-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[ + RunSourceConnectionWorkflow, + RecoverNativeProjectionWorkflow, + ProjectCanonicalRecordsWorkflow, + ], + activities=[capture, discover, project, mock_transition_sync_job(recorder)], + ): + handle = await env.client.start_workflow( + RunSourceConnectionWorkflow.run, + args=[ + make_sync_dict(), + make_sync_job_dict(), + make_collection_dict(), + make_connection_dict(), + make_ctx_dict(), + ], + id=uuid4().hex, + task_queue=queue, + ) + try: + await asyncio.wait_for(capturing.wait(), 20) + await env.client.execute_workflow( + RecoverNativeProjectionWorkflow.run, id=uuid4().hex, task_queue=queue + ) + await asyncio.wait_for(projected.wait(), 20) + assert not finish_capture.is_set() + finish_capture.set() + await handle.result() + assert recorder.called("transition_completed") + assert calls == [SYNC_ID] + finally: + finish_capture.set() + finish_projection.set() + await env.client.get_workflow_handle( + f"canonical-projection:{ORG_ID}:{SYNC_ID}" + ).result() + + +@workflow.defn(name="RecoverNativeProjectionWorkflow") +class PreviousRecoveryWorkflow: + """The historical native-only child ID must remain replayable.""" + + @workflow.run + async def run(self, after_sync_id: str | None = None) -> None: + page = await workflow.execute_activity( + "discover_native_projection_activity", + after_sync_id, + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + for source in page["sources"]: + await workflow.start_child_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[source["organization_id"], source["sync_id"], None, 0, 0, True], + id=f"native-projection:{source['organization_id']}:{source['sync_id']}", + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + + +async def test_previous_source_projection_identity_replays(): + organization, sync = str(uuid4()), str(uuid4()) + + @activity.defn(name="discover_native_projection_activity") + async def discover(after: str | None = None): + return { + "sources": [{"organization_id": organization, "sync_id": sync}], + "next_cursor": None, + } + + @activity.defn(name="project_canonical_records_activity") + async def project(org: str, sync: str, after: str | None = None, skip_failed: bool = False): + return {"after_id": None, "has_more": False, "failed": 0, "published": 1} + + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "previous-projection-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[PreviousRecoveryWorkflow, ProjectCanonicalRecordsWorkflow], + activities=[discover, project], + ): + handle = await env.client.start_workflow( + PreviousRecoveryWorkflow.run, id=uuid4().hex, task_queue=queue + ) + await handle.result() + history = await handle.fetch_history() + await Replayer(workflows=[RecoverNativeProjectionWorkflow]).replay_workflow(history) diff --git a/backend/airweave/domains/temporal/workflows/tests/test_reproject_command.py b/backend/airweave/domains/temporal/workflows/tests/test_reproject_command.py new file mode 100644 index 0000000000..08950a1ed9 --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/tests/test_reproject_command.py @@ -0,0 +1,47 @@ +"""Operator retry intent must not disappear behind automatic projection.""" + +from argparse import Namespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from temporalio.exceptions import WorkflowAlreadyStartedError + +from airweave.domains.entities.canonical.reprojection import ReprojectionPlan +from scripts import reproject_canonical + + +async def test_busy_projection_preserves_committed_version_but_reports_retry(monkeypatch, capsys): + organization, sync = uuid4(), uuid4() + args = Namespace(organization=organization, sync=sync, expect=2, target=3, apply=True) + plan = ReprojectionPlan( + organization_id=organization, + sync_id=sync, + source_connection_id=uuid4(), + provider="slack", + current_version=2, + target_version=3, + captured_records=1, + target_pending_records=1, + workflow_id=f"canonical-projection:{organization}:{sync}", + applied=True, + ) + session = AsyncMock() + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=session) + context.__aexit__ = AsyncMock(return_value=False) + monkeypatch.setattr(reproject_canonical, "AsyncSessionLocal", lambda: context) + monkeypatch.setattr(reproject_canonical, "plan_reprojection", AsyncMock(return_value=plan)) + client = AsyncMock() + client.start_workflow.side_effect = WorkflowAlreadyStartedError(plan.workflow_id, "projection") + monkeypatch.setattr(reproject_canonical, "get_client", AsyncMock(return_value=client)) + + with pytest.raises(SystemExit, match="retry this exact command"): + await reproject_canonical.run(args) + + session.commit.assert_awaited_once() + assert "accepted" not in capsys.readouterr().out + call = client.start_workflow.call_args.kwargs + assert call["id"] == plan.workflow_id + assert call["args"] == [str(organization), str(sync)] + assert "id_conflict_policy" not in call diff --git a/backend/airweave/domains/temporal/workflows/tests/test_source_projection_replay.py b/backend/airweave/domains/temporal/workflows/tests/test_source_projection_replay.py new file mode 100644 index 0000000000..4321c6af75 --- /dev/null +++ b/backend/airweave/domains/temporal/workflows/tests/test_source_projection_replay.py @@ -0,0 +1,156 @@ +"""Replay real pre-shared-ID source histories against the current workflow.""" + +import asyncio +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +import pytest +from temporalio import activity, workflow +from temporalio.client import WorkflowFailureError +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Replayer, Worker + +from airweave.domains.temporal.activities import run_sync_activity +from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, +) +from airweave.domains.temporal.workflows.run_source_connection import RunSourceConnectionWorkflow + +from .conftest import ( + ActivityRecorder, + make_collection_dict, + make_connection_dict, + make_ctx_dict, + make_sync_dict, + make_sync_job_dict, + mock_run_sync, + mock_transition_sync_job, +) + + +@workflow.defn(name="RunSourceConnectionWorkflow") +class PreviousSourceWorkflow(RunSourceConnectionWorkflow): + """Keep the former finalizer's ID, arguments and execution policies verbatim.""" + + @workflow.run + async def run( + self, + sync_dict: dict[str, Any], + sync_job_dict: dict[str, Any] | None, + collection_dict: dict[str, Any], + connection_dict: dict[str, Any], + ctx_dict: dict[str, Any], + access_token: str | None = None, + force_full_sync: bool = False, + ) -> None: + await super().run( + sync_dict, + sync_job_dict, + collection_dict, + connection_dict, + ctx_dict, + access_token, + force_full_sync, + ) + + async def _execute_sync( + self, + sync_dict, + sync_job_dict, + collection_dict, + connection_dict, + ctx_dict, + access_token, + force_full_sync, + ) -> None: + heartbeat = ( + timedelta(hours=1) + if ctx_dict.get("local_development", False) + else timedelta(minutes=15) + ) + try: + await workflow.execute_activity( + run_sync_activity, + args=[ + sync_dict, + sync_job_dict, + collection_dict, + connection_dict, + ctx_dict, + access_token, + force_full_sync, + ], + start_to_close_timeout=timedelta(days=7), + heartbeat_timeout=heartbeat, + cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + retry_policy=RetryPolicy(maximum_attempts=1), + ) + finally: + await asyncio.shield( + workflow.start_child_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[str(ctx_dict["organization"]["id"]), str(sync_dict["id"])], + id=f"projection:{sync_dict['id']}:{workflow.info().run_id}", + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + ) + + +@pytest.mark.parametrize("local,fails", [(False, False), (True, True)]) +async def test_old_source_projection_finalizer_history_replays(local, fails): + recorder = ActivityRecorder() + + @activity.defn(name="project_canonical_records_activity") + async def project(organization: str, sync: str, after: str | None = None): + return {"after_id": None, "has_more": False, "failed": 0, "published": 0} + + ctx = make_ctx_dict() + ctx["local_development"] = local + sync = make_sync_dict() + error = ApplicationError("Synthetic capture failure", non_retryable=True) if fails else None + async with await WorkflowEnvironment.start_time_skipping() as env: + queue = "source-projection-replay-" + uuid4().hex + async with Worker( + env.client, + task_queue=queue, + workflows=[PreviousSourceWorkflow, ProjectCanonicalRecordsWorkflow], + activities=[ + mock_run_sync(recorder, error), + mock_transition_sync_job(recorder), + project, + ], + ): + handle = await env.client.start_workflow( + PreviousSourceWorkflow.run, + args=[ + sync, + make_sync_job_dict(), + make_collection_dict(), + make_connection_dict(), + ctx, + "synthetic-token", + True, + ], + id=uuid4().hex, + task_queue=queue, + ) + if fails: + with pytest.raises(WorkflowFailureError): + await handle.result() + else: + await handle.result() + history = await handle.fetch_history() + children = [ + event.start_child_workflow_execution_initiated_event_attributes + for event in history.events + if event.HasField("start_child_workflow_execution_initiated_event_attributes") + ] + assert len(children) == 1 + assert children[0].workflow_id.startswith(f"projection:{sync['id']}:") + assert len(children[0].input.payloads) == 2 + await env.client.get_workflow_handle(children[0].workflow_id).result() + await Replayer(workflows=[RunSourceConnectionWorkflow]).replay_workflow(history) + assert recorder.called("transition_failed" if fails else "transition_completed") diff --git a/backend/airweave/main.py b/backend/airweave/main.py index 2702ea968b..2064278cb6 100644 --- a/backend/airweave/main.py +++ b/backend/airweave/main.py @@ -4,9 +4,6 @@ and unhandled exceptions. """ -import os -import subprocess -import sys from contextlib import asynccontextmanager from fastapi import FastAPI @@ -34,6 +31,7 @@ ) from airweave.api.router import TrailingSlashRouter from airweave.api.v1.api import api_router +from airweave.api.v1.endpoints.records import record_error_response from airweave.core.config import settings from airweave.core.exceptions import ( AirweaveException, @@ -44,16 +42,16 @@ RateLimitExceededException, ) from airweave.core.logging import logger -from airweave.db.init_db import init_db from airweave.db.session import AsyncSessionLocal from airweave.domains.embedders.config import validate_embedding_config +from airweave.domains.entities.canonical.store import CanonicalStoreError @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown events. - Initializes the DI container, runs alembic migrations, and syncs platform components. + Initialize runtime dependencies against an explicitly provisioned database. """ # Initialize the dependency injection container (fail fast if wiring is broken) from airweave.core import container as container_mod @@ -64,19 +62,6 @@ async def lifespan(app: FastAPI): logger.info("Container initialized successfully") async with AsyncSessionLocal() as db: - if settings.RUN_ALEMBIC_MIGRATIONS: - logger.info("Running alembic migrations...") - env = os.environ.copy() - backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - env["PYTHONPATH"] = backend_dir - subprocess.run( - [sys.executable, "-m", "alembic", "upgrade", "head"], - check=True, - cwd=backend_dir, - env=env, - ) - await init_db(db) - # Reconcile embedding config against DB deployment metadata await validate_embedding_config(db) @@ -86,6 +71,10 @@ async def lifespan(app: FastAPI): await container_mod.container.temporal_schedule_service.ensure_system_schedules() logger.info("System Temporal schedules initialized successfully") except Exception as e: + if "temporal" in settings.health_critical_probes: + # The cached health probe cannot initialize a missing connection. + # Let the process supervisor retry startup for a required service. + raise logger.warning( f"Failed to initialize system schedules (Temporal may not be available): {e}" ) @@ -141,6 +130,7 @@ async def lifespan(app: FastAPI): # Register custom Airweave exception handlers app.exception_handler(AirweaveException)(airweave_exception_handler) +app.exception_handler(CanonicalStoreError)(record_error_response) # Default CORS origins - white labels and environment variables can extend this CORS_ORIGINS = [ diff --git a/backend/airweave/models/__init__.py b/backend/airweave/models/__init__.py index f755072eee..c95bf93808 100644 --- a/backend/airweave/models/__init__.py +++ b/backend/airweave/models/__init__.py @@ -4,10 +4,12 @@ from .api_key import APIKey from .auth_provider import AuthProvider from .billing_period import BillingPeriod +from .capture_scan import CaptureScan from .collection import Collection from .connection import Connection from .connection_init_session import ConnectionInitSession from .entity import Entity +from .entity_change import EntityChange from .entity_count import EntityCount from .entity_definition import EntityDefinition from .entity_relation import EntityRelation @@ -16,6 +18,7 @@ from .node_selection import NodeSelection from .organization import Organization from .organization_billing import OrganizationBilling +from .owned_provisioning import OwnedProvisioning from .processed_webhook_event import ProcessedWebhookEvent from .redirect_session import RedirectSession from .search_query import SearchQuery @@ -31,12 +34,16 @@ from .vector_db_deployment_metadata import VectorDbDeploymentMetadata __all__ = [ + "OwnedProvisioning", "AccessControlMembership", "APIKey", "AuthProvider", "BillingPeriod", "Collection", "Entity", + "EntityChange", + "CaptureScan", + "ProjectionGeneration", "EntityCount", "Connection", "ConnectionInitSession", @@ -61,3 +68,5 @@ "UserOrganization", "VectorDbDeploymentMetadata", ] + +from .projection_generation import ProjectionGeneration diff --git a/backend/airweave/models/capture_scan.py b/backend/airweave/models/capture_scan.py new file mode 100644 index 0000000000..fcae496086 --- /dev/null +++ b/backend/airweave/models/capture_scan.py @@ -0,0 +1,61 @@ +"""One recoverable whole-scope scan, independent of worker attempts.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from airweave.models._base import OrganizationBase + + +class CaptureScan(OrganizationBase): + """Only the canonical capture transaction boundary mutates this state.""" + + __tablename__ = "capture_scan" + sync_id: Mapped[UUID] = mapped_column(ForeignKey("sync.id", ondelete="CASCADE")) + scope_key: Mapped[str] = mapped_column(String) + record_type: Mapped[str] = mapped_column(String) + container_id: Mapped[str | None] = mapped_column(String) + parent_record_id: Mapped[UUID | None] = mapped_column( + ForeignKey("entity.id", ondelete="CASCADE") + ) + parent_visibility_epoch: Mapped[int | None] = mapped_column(BigInteger) + parent_verified_attempt_id: Mapped[UUID | None] = mapped_column(nullable=True) + parent_verified_revision: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + membership_attempt_id: Mapped[UUID | None] + cycle_id: Mapped[UUID] + sweep_id: Mapped[UUID] + revision: Mapped[int] = mapped_column(BigInteger) + phase: Mapped[str] = mapped_column(String) + fingerprint: Mapped[str] = mapped_column(String(64)) + continuation: Mapped[dict] = mapped_column(JSONB) + execution_state: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + Index( + "idx_capture_scan_parent", + "sync_id", + "parent_record_id", + "record_type", + unique=True, + postgresql_where=text("parent_record_id IS NOT NULL"), + ), + UniqueConstraint("sync_id", "scope_key", name="uq_capture_scan_scope"), + CheckConstraint( + "phase IN ('collecting','reconciling','complete')", name="ck_capture_scan_phase" + ), + CheckConstraint("revision >= 1", name="ck_capture_scan_revision"), + ) diff --git a/backend/airweave/models/entity.py b/backend/airweave/models/entity.py index a5aa81522d..2f3682a01f 100644 --- a/backend/airweave/models/entity.py +++ b/backend/airweave/models/entity.py @@ -1,9 +1,20 @@ """Entity model.""" +from datetime import datetime from typing import TYPE_CHECKING, Optional from uuid import UUID -from sqlalchemy import ForeignKey, Index, String, UniqueConstraint +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from airweave.models._base import OrganizationBase @@ -25,8 +36,8 @@ class Entity(OrganizationBase): index=False, # Disabled: billions of rows, not selective, better indexes on sync_id ) - sync_job_id: Mapped[UUID] = mapped_column( - ForeignKey("sync_job.id", ondelete="CASCADE", name="fk_entity_sync_job_id"), nullable=False + sync_job_id: Mapped[Optional[UUID]] = mapped_column( + ForeignKey("sync_job.id", ondelete="SET NULL", name="fk_entity_sync_job_id"), nullable=True ) sync_id: Mapped[UUID] = mapped_column( ForeignKey("sync.id", ondelete="CASCADE", name="fk_entity_sync_id"), nullable=False @@ -39,8 +50,35 @@ class Entity(OrganizationBase): ) hash: Mapped[str] = mapped_column(String, nullable=False) + # Canonical source state. Revision zero identifies legacy metadata-only rows. + native_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + container_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + parent_record_type: Mapped[Optional[str]] = mapped_column(String, nullable=True) + parent_native_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + parent_container_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + visibility_epoch: Mapped[int] = mapped_column(BigInteger, default=1, server_default="1") + parent_visibility_epoch: Mapped[Optional[int]] = mapped_column(BigInteger) + source_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + payload_schema_version: Mapped[int] = mapped_column(default=1, server_default="1") + record_revision: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + capture_hash: Mapped[Optional[str]] = mapped_column(String, nullable=True) + content_hash: Mapped[Optional[str]] = mapped_column(String, nullable=True) + source_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + source_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + observed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + removal_reason: Mapped[Optional[str]] = mapped_column(String) + completeness: Mapped[Optional[str]] = mapped_column(String) + blob_references: Mapped[Optional[list]] = mapped_column(JSONB) + last_seen_run_id: Mapped[Optional[UUID]] = mapped_column(nullable=True) + indexed_generation: Mapped[Optional[UUID]] = mapped_column(nullable=True) + indexed_chunk_count: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + indexed_revision: Mapped[Optional[int]] = mapped_column(BigInteger) + indexed_pipeline_version: Mapped[Optional[int]] = mapped_column(BigInteger) + projection_error: Mapped[Optional[str]] = mapped_column(String) + # Add back references - sync_job: Mapped["SyncJob"] = relationship( + sync_job: Mapped[Optional["SyncJob"]] = relationship( "SyncJob", back_populates="entities", lazy="noload", @@ -53,6 +91,36 @@ class Entity(OrganizationBase): ) __table_args__ = ( + CheckConstraint( + "visibility_epoch >= 1 AND " + "(parent_visibility_epoch IS NULL OR parent_visibility_epoch >= 1)", + name="ck_entity_visibility_epoch", + ), + Index( + "idx_entity_canonical_identity", + "sync_id", + "entity_definition_short_name", + "native_id", + "container_id", + ), + Index( + "idx_entity_canonical_parent", + "sync_id", + "parent_record_type", + "parent_native_id", + "parent_container_id", + ), + Index( + "idx_entity_canonical_scope", "sync_id", "entity_definition_short_name", "container_id" + ), + Index( + "idx_entity_pending_revision", + "sync_id", + "id", + postgresql_where=text( + "record_revision > 0 AND indexed_revision IS DISTINCT FROM record_revision" + ), + ), UniqueConstraint( "sync_id", "entity_id", diff --git a/backend/airweave/models/entity_change.py b/backend/airweave/models/entity_change.py new file mode 100644 index 0000000000..da8c05450e --- /dev/null +++ b/backend/airweave/models/entity_change.py @@ -0,0 +1,24 @@ +"""Immutable snapshots of observed canonical record revisions.""" + +from uuid import UUID + +from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from airweave.models._base import OrganizationBase + + +class EntityChange(OrganizationBase): + """Per-sync commit-ordered journal; snapshot survives later record updates.""" + + __tablename__ = "entity_change" + + sync_id: Mapped[UUID] = mapped_column(ForeignKey("sync.id", ondelete="CASCADE")) + entity_record_id: Mapped[UUID] = mapped_column(ForeignKey("entity.id", ondelete="CASCADE")) + sequence: Mapped[int] = mapped_column(BigInteger) + record_revision: Mapped[int] = mapped_column(BigInteger) + kind: Mapped[str] = mapped_column(String) + snapshot: Mapped[dict] = mapped_column(JSONB) + + __table_args__ = (UniqueConstraint("sync_id", "sequence", name="uq_entity_change_sequence"),) diff --git a/backend/airweave/models/owned_provisioning.py b/backend/airweave/models/owned_provisioning.py new file mode 100644 index 0000000000..3857c1f5ea --- /dev/null +++ b/backend/airweave/models/owned_provisioning.py @@ -0,0 +1,44 @@ +"""One desired Almanac account/source relationship, not another record store.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from airweave.models._base import OrganizationBase + + +class OwnedProvisioning(OrganizationBase): + """Durable idempotency and execution intent for the fixed Almanac client.""" + + __tablename__ = "owned_provisioning" + client_namespace: Mapped[str] = mapped_column(String(32), nullable=False) + account_id: Mapped[UUID] = mapped_column(nullable=False) + generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + request_hash: Mapped[str] = mapped_column(String(64), nullable=False) + request_payload: Mapped[dict] = mapped_column(JSONB, nullable=False) + desired_state: Mapped[str] = mapped_column(String(20), nullable=False) + observed_generation: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False) + source_connection_id: Mapped[UUID | None] = mapped_column( + ForeignKey("source_connection.id", ondelete="RESTRICT"), nullable=True + ) + sync_id: Mapped[UUID | None] = mapped_column( + ForeignKey("sync.id", ondelete="RESTRICT"), nullable=True + ) + initial_job_id: Mapped[UUID | None] = mapped_column( + ForeignKey("sync_job.id", ondelete="SET NULL"), nullable=True + ) + cancellation_job_ids: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False) + verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "organization_id", + "client_namespace", + "account_id", + name="uq_owned_provisioning_account", + ), + UniqueConstraint("sync_id", name="uq_owned_provisioning_sync"), + ) diff --git a/backend/airweave/models/projection_generation.py b/backend/airweave/models/projection_generation.py new file mode 100644 index 0000000000..7bb510f27e --- /dev/null +++ b/backend/airweave/models/projection_generation.py @@ -0,0 +1,37 @@ +"""Body-free deletion manifest survives removal of the source and its records.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import BigInteger, DateTime, Index, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from airweave.models._base import Base + + +class ProjectionGeneration(Base): + """Immutable feed identity; retired generations can never publish again.""" + + __tablename__ = "projection_generation" + organization_id: Mapped[UUID] = mapped_column(index=True) + sync_id: Mapped[UUID] = mapped_column(index=True) + collection_id: Mapped[UUID] = mapped_column() + record_id: Mapped[UUID] = mapped_column(index=True) + revision: Mapped[int] = mapped_column(BigInteger) + pipeline_version: Mapped[int] = mapped_column(BigInteger) + documents: Mapped[list] = mapped_column(JSONB) + extraction_coverage: Mapped[dict | None] = mapped_column( + JSONB(none_as_null=True), nullable=True + ) + text_representations: Mapped[list | None] = mapped_column( + JSONB(none_as_null=True), nullable=True + ) + retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_gc_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + last_gc_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delete_cursor: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + gc_passes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + gc_attempt: Mapped[UUID | None] = mapped_column(nullable=True) + gc_error: Mapped[str | None] = mapped_column(String(200), nullable=True) + __table_args__ = (Index("idx_projection_gc_due", "next_gc_at", "id"),) diff --git a/backend/airweave/models/sync.py b/backend/airweave/models/sync.py index baa0ce4c49..4948495376 100644 --- a/backend/airweave/models/sync.py +++ b/backend/airweave/models/sync.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Optional from uuid import UUID -from sqlalchemy import JSON, DateTime, String, event, text +from sqlalchemy import JSON, BigInteger, DateTime, String, event, text from sqlalchemy import Connection as SAConnection from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -41,6 +41,18 @@ class Sync(OrganizationBase, UserMixin): sync_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) sync_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + provisioning_generation: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + provisioning_ready_generation: Mapped[int] = mapped_column( + BigInteger, default=0, server_default="0" + ) + + observed_change_sequence: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + writer_epoch: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + writer_job_id: Mapped[Optional[UUID]] = mapped_column(nullable=True) + writer_attempt_id: Mapped[Optional[UUID]] = mapped_column(nullable=True) + writer_attempt_number: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + index_pipeline_version: Mapped[int] = mapped_column(BigInteger, default=1, server_default="1") + jobs: Mapped[list["SyncJob"]] = relationship( "SyncJob", back_populates="sync", diff --git a/backend/airweave/models/sync_job.py b/backend/airweave/models/sync_job.py index 2dc87524df..ffbdf312b5 100644 --- a/backend/airweave/models/sync_job.py +++ b/backend/airweave/models/sync_job.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional from uuid import UUID -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String +from sqlalchemy import JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -24,6 +24,7 @@ class SyncJob(OrganizationBase, UserMixin): sync_id: Mapped[UUID] = mapped_column( ForeignKey("sync.id", ondelete="CASCADE", name="fk_sync_job_sync_id"), nullable=False ) + provisioning_generation: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") status: Mapped[str] = mapped_column(String(50), default=SyncJobStatus.PENDING.value) started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) @@ -50,7 +51,6 @@ class SyncJob(OrganizationBase, UserMixin): "Entity", back_populates="sync_job", lazy="noload", - cascade="all, delete-orphan", passive_deletes=True, ) diff --git a/backend/airweave/platform/configs/config.py b/backend/airweave/platform/configs/config.py index 77d8d004c7..4d90d582ed 100644 --- a/backend/airweave/platform/configs/config.py +++ b/backend/airweave/platform/configs/config.py @@ -1,9 +1,18 @@ """Configuration classes for platform components.""" -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Literal, Optional - -from pydantic import Field, field_validator +from uuid import UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + StrictBool, + field_validator, + model_validator, +) from airweave.platform.configs._base import BaseConfig, RequiredTemplateConfig from airweave.platform.utils.ssrf import validate_host, validate_url @@ -34,9 +43,9 @@ class ApolloConfig(SourceConfig): class AttioConfig(SourceConfig): - """Attio configuration schema.""" + """Attested workspace boundary for native CRM capture.""" - pass + workspace_id: UUID = Field(description="Native Attio workspace UUID; verified using /v2/self") class BitbucketConfig(SourceConfig): @@ -175,52 +184,51 @@ class ElasticsearchConfig(SourceConfig): pass -class GitHubConfig(SourceConfig): - """Github configuration schema.""" +class GitHubRepositorySelection(BaseModel): + """Stable authorized repository/owner identities plus a refreshable native route.""" - repo_name: str = Field( - title="Repository Name", - description="Repository to sync in owner/repo format (e.g., 'airweave-ai/airweave')", - min_length=3, - pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$", - ) - branch: str = Field( - default="", - title="Branch name", - description=( - "Specific branch to sync (e.g., 'main', 'development'). " - "If empty, uses the default branch." - ), + model_config = ConfigDict(extra="forbid", frozen=True) + repository_id: int = Field(gt=0, strict=True) + owner_id: int = Field(gt=0, strict=True) + full_name: str = Field(pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$", max_length=300) + ref: str | None = Field( + default=None, + min_length=1, + max_length=1024, + description="Selected branch name; defaults to the repository default branch", ) - sync_pull_requests: bool = Field( - default=False, - title="Sync Pull Requests", - description=( - "Sync merged pull requests and their review comments. " - "Enables searching over PR descriptions, discussions, and code review feedback." - ), + + +class GitHubConfig(SourceConfig): + """Selected repositories captured through one owned account and durable page engine.""" + + model_config = ConfigDict(extra="forbid") + expected_user_id: int = Field( + gt=0, + strict=True, + description="Expected native GitHub user ID; verified using authenticated /user", ) + repositories: tuple[GitHubRepositorySelection, ...] = Field(min_length=1, max_length=500) + include_code: bool = True + include_conversations: bool = True - @field_validator("repo_name") + @model_validator(mode="before") @classmethod - def validate_repo_name(cls, v: str) -> str: - """Validate repository name is in owner/repo format.""" - if not v or not v.strip(): - raise ValueError("Repository name is required") - v = v.strip() - if "/" not in v: - raise ValueError( - "Repository must be in 'owner/repo' format (e.g., 'airweave-ai/airweave')" - ) - parts = v.split("/") - if len(parts) != 2: - raise ValueError( - "Repository must be in 'owner/repo' format (e.g., 'airweave-ai/airweave')" - ) - owner, repo = parts - if not owner or not repo: - raise ValueError("Both owner and repository name must be non-empty") - return v + def require_stable_selection(cls, value): + """Old name-only configurations cannot silently authorize a replacement repository.""" + if isinstance(value, dict) and "repo_name" in value: + raise ValueError("Refresh GitHub repository selections with repository and owner IDs") + return value + + @model_validator(mode="after") + def unique_selection(self) -> "GitHubConfig": + """One selected ref per repository; no ambiguous duplicate scope owners.""" + ids = [selection.repository_id for selection in self.repositories] + if len(ids) != len(set(ids)): + raise ValueError("GitHub repository selections must be unique") + if not self.include_code and not self.include_conversations: + raise ValueError("Select GitHub code or conversations to capture") + return self class GitLabConfig(SourceConfig): @@ -245,6 +253,15 @@ class GitLabConfig(SourceConfig): class GmailConfig(SourceConfig): """Gmail configuration schema.""" + expected_mailbox: str | None = Field( + default=None, + pattern=r"^[^\s@]+@[^\s@]+$", + description=( + "Native mailbox identity attested by the trusted account binding. " + "Required for owned capture." + ), + ) + after_date: Optional[str] = Field( None, title="After Date", @@ -307,10 +324,56 @@ def validate_date_format(cls, value): return value.replace("-", "/") +class CalendarOccurrenceWindow(BaseConfig): + """Explicit operator-requested capture window; dates must carry timezones.""" + + start: AwareDatetime + end: AwareDatetime + + @model_validator(mode="after") + def bounded(self): + """Keep one explicit capture request bounded and nonempty.""" + if self.start.microsecond or self.end.microsecond: + raise ValueError("Calendar capture bounds require whole-second precision") + if not timedelta(0) < self.end - self.start <= timedelta(days=366): + raise ValueError("Calendar capture window must be positive and at most366days") + return self + + class GoogleCalendarConfig(SourceConfig): - """Google Calendar configuration schema.""" + """Rolling expanded capture, or an explicit window through source configuration.""" - pass + expected_primary_calendar_id: str | None = Field( + default=None, + min_length=1, + pattern=r"^\S+$", + description="Trusted primary calendar ID; required for owned capture.", + ) + + calendar_ids: tuple[str, ...] | None = Field(default=None, max_length=250) + + @field_validator("calendar_ids") + @classmethod + def unique_calendars(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None: + """None selects all, empty selects none; only exact native IDs are accepted.""" + if value is not None and ( + len(set(value)) != len(value) + or any(not item.strip() or item != item.strip() or item == "primary" for item in value) + ): + raise ValueError("Use unique exact calendar IDs, not blank values or primary alias") + return value + + occurrence_past_days: int = Field(default=30, ge=0, le=180) + occurrence_future_days: int = Field(default=90, ge=1, le=186) + occurrence_window: CalendarOccurrenceWindow | None = None + + def resolved_window(self) -> CalendarOccurrenceWindow: + """Freeze relative bounds once for the whole paginated source run.""" + now = datetime.now(timezone.utc).replace(microsecond=0) + return self.occurrence_window or CalendarOccurrenceWindow( + start=now - timedelta(days=self.occurrence_past_days), + end=now + timedelta(days=self.occurrence_future_days), + ) class GoogleDocsConfig(SourceConfig): @@ -332,6 +395,18 @@ class GoogleDocsConfig(SourceConfig): class GoogleDriveConfig(SourceConfig): """Google Drive configuration schema.""" + expected_permission_id: str | None = Field( + default=None, + min_length=1, + pattern=r"^\S+$", + description="Trusted native Drive permission ID; required for owned canonical capture.", + ) + + capture_native_sheets: bool = Field( + default=False, + description="Enable bounded native Sheets capture after verifying account API access.", + ) + include_patterns: list[str] = Field( default=[], title="Include Patterns", @@ -428,9 +503,22 @@ class JiraConfig(SourceConfig): class LinearConfig(SourceConfig): - """Linear configuration schema.""" + """Explicit workspace and team scope for owned Linear originals.""" - pass + workspace_id: UUID = Field(description="Expected native Linear organization UUID") + team_ids: tuple[UUID, ...] = Field( + min_length=1, + max_length=100, + description="Exact selected native team UUIDs; no implicit workspace-wide default", + ) + + @field_validator("team_ids") + @classmethod + def unique_teams(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]: + """Reject ambiguous duplicate configuration rather than silently rewriting it.""" + if len(value) != len(set(value)): + raise ValueError("Linear team IDs must be unique") + return value class MondayConfig(SourceConfig): @@ -446,9 +534,11 @@ class MySQLConfig(SourceConfig): class NotionConfig(SourceConfig): - """Notion configuration schema.""" + """Verified workspace and connection bot identity for owned Notion capture.""" - pass + model_config = ConfigDict(extra="forbid") + expected_workspace_id: UUID + expected_bot_id: UUID class OneDriveConfig(SourceConfig): @@ -490,14 +580,65 @@ class OracleConfig(SourceConfig): class OutlookCalendarConfig(SourceConfig): - """Outlook Calendar configuration schema.""" + """Owned calendar originals with bounded occurrence expansion on a fresh source.""" - pass + capture_originals: bool = Field( + default=False, + strict=True, + description="Use owned immutable-event capture; legacy source IDs are not migrated.", + ) + occurrence_past_days: int = Field(default=30, ge=0, le=180) + occurrence_future_days: int = Field(default=90, ge=1, le=186) + occurrence_window: CalendarOccurrenceWindow | None = None + + @model_validator(mode="after") + def require_capture_principal(self): + """Owned capture cannot inherit an unattested legacy mailbox identity.""" + if self.capture_originals and self.expected_principal_id is None: + raise ValueError("Original Outlook Calendar capture requires expected_principal_id") + return self + + def resolved_window(self) -> CalendarOccurrenceWindow: + """Freeze relative expansion bounds once before the durable cycle starts.""" + now = datetime.now(timezone.utc).replace(microsecond=0) + return self.occurrence_window or CalendarOccurrenceWindow( + start=now - timedelta(days=self.occurrence_past_days), + end=now + timedelta(days=self.occurrence_future_days), + ) + + expected_principal_id: str | None = Field( + default=None, + min_length=1, + pattern=r"^\S+$", + description="Trusted Microsoft Graph /me ID; required for managed connections.", + ) class OutlookMailConfig(SourceConfig): """Outlook Mail configuration schema.""" + capture_originals: bool = Field( + default=False, + strict=True, + description=( + "Use owned immutable-message capture on a fresh source; legacy IDs are not migrated." + ), + ) + + @model_validator(mode="after") + def require_capture_principal(self): + """Original capture must not rely on an unattested legacy mailbox.""" + if self.capture_originals and self.expected_principal_id is None: + raise ValueError("Original Outlook capture requires expected_principal_id") + return self + + expected_principal_id: str | None = Field( + default=None, + min_length=1, + pattern=r"^\S+$", + description="Trusted Microsoft Graph /me ID; required for managed connections.", + ) + after_date: Optional[str] = Field( None, title="After Date", @@ -726,9 +867,23 @@ def validate_host_ssrf(cls, v: str) -> str: class SlackConfig(SourceConfig): - """Slack configuration schema.""" + """Trusted native workspace/user pair for owned Slack capture.""" - pass + capture_files: bool = Field( + default=False, + description="Opt in to message-owned file children, each committed independently; " + "leave disabled until reader and migration qualification complete.", + ) + + expected_team_id: str | None = Field(default=None, min_length=1, pattern=r"^\S+$") + expected_user_id: str | None = Field(default=None, min_length=1, pattern=r"^\S+$") + + @model_validator(mode="after") + def complete_principal(self): + """A workspace alone cannot attest a user's private-channel visibility.""" + if (self.expected_team_id is None) != (self.expected_user_id is None): + raise ValueError("Slack identity requires both team and user IDs") + return self class SQLServerConfig(SourceConfig): @@ -743,10 +898,29 @@ class SQliteConfig(SourceConfig): pass +class StripeCaptureConfig(BaseModel): + """Trusted immutable source identity; different accounts/modes need fresh sources.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + expected_account_id: str = Field(pattern=r"^acct_[A-Za-z0-9]+$") + livemode: StrictBool + api_version: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}(?:\.[a-z]+)?$") + connected_account_id: str | None = Field(default=None, pattern=r"^acct_[A-Za-z0-9]+$") + + @model_validator(mode="after") + def consistent_connect_context(self): + """The selected Connect account is the effective native account, not its platform.""" + if self.connected_account_id is not None and ( + self.connected_account_id != self.expected_account_id + ): + raise ValueError("Stripe Connect context must match expected_account_id") + return self + + class StripeConfig(SourceConfig): - """Stripe configuration schema.""" + """Legacy entity ingestion, or explicitly bound original capture on a fresh source.""" - pass + original_capture: StripeCaptureConfig | None = None class PipedriveConfig(SourceConfig): @@ -1183,6 +1357,12 @@ class ComposioConfig(AuthProviderConfig): description="Account ID for the Composio connection", ) + user_id: str | None = Field( + default=None, + title="User ID", + description="Explicit Composio user binding required for managed tool sessions", + ) + class CustomConfig(AuthProviderConfig): """Custom Auth Provider configuration schema.""" diff --git a/backend/airweave/platform/cursors/google_calendar.py b/backend/airweave/platform/cursors/google_calendar.py new file mode 100644 index 0000000000..89f78db7ae --- /dev/null +++ b/backend/airweave/platform/cursors/google_calendar.py @@ -0,0 +1,25 @@ +"""Calendar delta tokens and successfully observed expanded-window coverage.""" + +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + +from airweave.platform.cursors._base import BaseCursor + + +class CalendarWindowCoverage(BaseModel): + """A completed observed scan, never a frozen provider snapshot.""" + + model_config = ConfigDict(extra="forbid") + start: AwareDatetime + end: AwareDatetime + timezone: str + completed_at: AwareDatetime + scan_id: UUID + + +class GoogleCalendarCursor(BaseCursor): + """Master deltas and expanded rows have independent source semantics.""" + + calendar_tokens: dict[str, str] = Field(default_factory=dict) + occurrence_coverage: dict[str, CalendarWindowCoverage] = Field(default_factory=dict) diff --git a/backend/airweave/platform/cursors/google_drive.py b/backend/airweave/platform/cursors/google_drive.py index bb5ab55f91..bacfe2fae6 100644 --- a/backend/airweave/platform/cursors/google_drive.py +++ b/backend/airweave/platform/cursors/google_drive.py @@ -16,6 +16,8 @@ class GoogleDriveCursor(BaseCursor): Reference: https://developers.google.com/drive/api/guides/manage-changes """ + canonical_page_token: str = "" + start_page_token: str = Field( default="", description="Drive Changes API page token for tracking incremental changes", diff --git a/backend/airweave/platform/destinations/vespa/client.py b/backend/airweave/platform/destinations/vespa/client.py index b279c317a2..7ca700b43e 100644 --- a/backend/airweave/platform/destinations/vespa/client.py +++ b/backend/airweave/platform/destinations/vespa/client.py @@ -420,10 +420,18 @@ def _parse_vespa_document_id(full_id: str) -> Tuple[Optional[str], Optional[str] schema = prefix_parts[2] return schema, doc_id + async def delete_documents(self, doc_ids: List[Tuple[str, str]]) -> None: + """Strict idempotent exact-ID deletion, with bounded HTTP concurrency.""" + if any(schema not in ALL_VESPA_SCHEMAS for schema, _ in doc_ids): + raise ValueError("Unknown Vespa schema in deletion manifest") + async with httpx.AsyncClient(timeout=settings.VESPA_TIMEOUT) as client: + await self._delete_by_doc_ids(doc_ids, http_client=client, strict=True) + async def _delete_by_doc_ids( self, doc_ids: List[Tuple[str, str]], http_client: httpx.AsyncClient, + *, strict: bool = False, ) -> int: """Delete documents by their Vespa document IDs using parallel direct DELETEs. @@ -432,6 +440,7 @@ async def _delete_by_doc_ids( Args: doc_ids: List of (schema, doc_id) tuples http_client: httpx client for issuing DELETE requests + strict: Fail partial deletion and accept already-absent documents as complete. Returns: Number of successfully deleted documents @@ -448,7 +457,7 @@ async def _delete_one(schema: str, doc_id: str) -> bool: url = f"{base_url}/document/v1/airweave/{schema}/docid/{quote(doc_id, safe='')}" async with semaphore: resp = await http_client.delete(url) - return resp.status_code == 200 + return resp.status_code in (200, 404) if strict else resp.status_code == 200 start = time.perf_counter() tasks = [_delete_one(schema, doc_id) for schema, doc_id in doc_ids] @@ -461,6 +470,8 @@ async def _delete_one(schema: str, doc_id: str) -> bool: else: failed += 1 + if failed > 0 and strict: + raise RuntimeError(f"Vespa deletion incomplete: {failed} documents failed") if failed > 0: self._logger.warning( f"[VespaClient] Direct delete: {deleted} ok, {failed} failed " diff --git a/backend/airweave/platform/destinations/vespa/destination.py b/backend/airweave/platform/destinations/vespa/destination.py index b92cd14d79..1b88ebc8dd 100644 --- a/backend/airweave/platform/destinations/vespa/destination.py +++ b/backend/airweave/platform/destinations/vespa/destination.py @@ -26,6 +26,7 @@ from airweave.platform.destinations.vespa.client import VespaClient from airweave.platform.destinations.vespa.query_builder import QueryBuilder from airweave.platform.destinations.vespa.transformer import EntityTransformer +from airweave.platform.destinations.vespa.types import VespaDocument from airweave.platform.entities._base import BaseEntity from airweave.schemas.search import AirweaveTemporalConfig from airweave.schemas.search_result import AirweaveSearchResult @@ -140,11 +141,30 @@ async def setup_collection(self, vector_size: Optional[int] = None) -> None: """ self.logger.debug("Vespa schema is managed via vespa-deploy, skipping setup_collection") - async def bulk_insert(self, entities: List[BaseEntity]) -> None: + def prepare_documents(self, entities: List[BaseEntity]) -> Dict[str, List[VespaDocument]]: + """Strictly transform once so the durable manifest matches the actual feed.""" + documents = self._transformer.transform_batch(entities) + if sum(len(group) for group in documents.values()) != len(entities): + raise RuntimeError("Vespa transformation dropped required projection chunks") + return documents + + async def feed_prepared(self, documents: Dict[str, List[VespaDocument]]) -> None: + """Feed precisely the already-manifested documents, failing partial results.""" + if self._client is None: + raise RuntimeError("Vespa client not initialized") + expected = sum(len(group) for group in documents.values()) + result = await self._client.feed_documents(documents) + if result.failed_docs: + self._handle_feed_failures(result.failed_docs, expected) + if result.success_count != expected: + raise RuntimeError("Vespa feed did not acknowledge every prepared document") + + async def bulk_insert(self, entities: List[BaseEntity], *, strict: bool = False) -> None: """Transform entities and batch feed to Vespa. Args: entities: List of entities to insert + strict: Reject any dropped transformation before feeding. """ if not entities: return @@ -162,6 +182,8 @@ async def bulk_insert(self, entities: List[BaseEntity]) -> None: # Convert to dict format for client docs_dict = dict(docs_by_schema.items()) total_docs = sum(len(docs) for docs in docs_dict.values()) + if strict and total_docs != len(entities): + raise RuntimeError("Vespa transformation dropped required projection chunks") transform_ms = (time.perf_counter() - transform_start) * 1000 self.logger.info( diff --git a/backend/airweave/platform/destinations/vespa/filter_translator.py b/backend/airweave/platform/destinations/vespa/filter_translator.py index 80eeac8492..0ca5d78744 100644 --- a/backend/airweave/platform/destinations/vespa/filter_translator.py +++ b/backend/airweave/platform/destinations/vespa/filter_translator.py @@ -3,7 +3,7 @@ Pure transformation logic with no I/O dependencies. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, Optional from airweave.core.logging import ContextualLogger @@ -33,8 +33,8 @@ "access.viewers": "access_viewers", } -# Fields stored as epoch milliseconds in Vespa -EPOCH_MS_FIELDS = {"created_at", "updated_at"} +# Fields stored as epoch seconds in Vespa +EPOCH_SECONDS_FIELDS = {"created_at", "updated_at"} class FilterTranslator: @@ -201,9 +201,9 @@ def _translate_range_condition(self, condition: Dict[str, Any]) -> str: for op, symbol in [("gt", ">"), ("gte", ">="), ("lt", "<"), ("lte", "<=")]: if op in range_cond: value = range_cond[op] - # Convert ISO datetime strings to epoch milliseconds for date fields - if key in EPOCH_MS_FIELDS and isinstance(value, str): - value = self._parse_datetime_to_epoch_ms(value) + # Convert ISO datetime strings to epoch seconds for date fields + if key in EPOCH_SECONDS_FIELDS and isinstance(value, str): + value = self._parse_datetime_to_epoch_seconds(value) parts.append(f"{key} {symbol} {value}") return " AND ".join(parts) if parts else "" @@ -234,13 +234,18 @@ def _escape_yql_value(self, value: str) -> str: """Escape special characters for YQL string literals.""" return value.replace("\\", "\\\\").replace('"', '\\"') - def _parse_datetime_to_epoch_ms(self, value: str) -> int: - """Parse ISO datetime string to epoch milliseconds.""" + def _parse_datetime_to_epoch_seconds(self, value: str) -> int | float | str: + """Parse ISO datetime string to epoch seconds.""" try: if value.endswith("Z"): value = value[:-1] + "+00:00" dt = datetime.fromisoformat(value) - return int(dt.timestamp() * 1000) + # Existing filters accept zone-less ISO values; interpret them as UTC, + # never as the deployment host timezone. New product inputs are aware. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + seconds = dt.timestamp() + return int(seconds) if seconds.is_integer() else seconds except (ValueError, AttributeError) as e: self._logger.warning(f"[FilterTranslator] Failed to parse datetime '{value}': {e}") return value diff --git a/backend/airweave/platform/destinations/vespa/transformer.py b/backend/airweave/platform/destinations/vespa/transformer.py index d118f6d621..835e5f48d9 100644 --- a/backend/airweave/platform/destinations/vespa/transformer.py +++ b/backend/airweave/platform/destinations/vespa/transformer.py @@ -323,6 +323,16 @@ def _build_system_metadata(self, entity: BaseEntity) -> Dict[str, Any]: if value is not None: meta_fields[field_name] = transform(value) if transform else value + # Explicit canonical fields; do not derive them from BaseEntity dates. + meta_fields.update( + { + "canonical_record_type": meta.canonical_record_type, + "source_created_us": meta.source_created_us, + "source_updated_us": meta.source_updated_us, + "source_created_known": meta.source_created_known, + "source_updated_known": meta.source_updated_known, + } + ) return meta_fields def _add_type_specific_fields(self, fields: Dict[str, Any], entity: BaseEntity) -> None: diff --git a/backend/airweave/platform/entities/__init__.py b/backend/airweave/platform/entities/__init__.py index 7496964bb6..9d177da0db 100644 --- a/backend/airweave/platform/entities/__init__.py +++ b/backend/airweave/platform/entities/__init__.py @@ -29,12 +29,7 @@ AsanaTaskEntity, AsanaWorkspaceEntity, ) -from .attio import ( - AttioListEntity, - AttioNoteEntity, - AttioObjectEntity, - AttioRecordEntity, -) +from .attio import AttioOriginalEntity from .bitbucket import ( BitbucketCodeFileEntity, BitbucketDirectoryEntity, @@ -121,6 +116,7 @@ GitHubPullRequestEntity, GithubRepoEntity, GitHubRepositoryEntity, + GitHubTextEntity, ) from .gitlab import ( GitLabCodeFileEntity, @@ -147,6 +143,7 @@ GoogleDriveDriveEntity, GoogleDriveFileDeletionEntity, GoogleDriveFileEntity, + GoogleDriveFolderEntity, ) from .google_slides import ( GoogleSlidesPresentationEntity, @@ -180,6 +177,7 @@ LinearAttachmentEntity, LinearCommentEntity, LinearIssueEntity, + LinearLinkedAttachmentEntity, LinearProjectEntity, LinearTeamEntity, LinearUserEntity, @@ -195,6 +193,7 @@ from .notion import ( NotionDatabaseEntity, NotionFileEntity, + NotionOriginalEntity, NotionPageEntity, NotionPropertyEntity, ) @@ -287,7 +286,7 @@ SlabPostEntity, SlabTopicEntity, ) -from .slack import SlackMessageEntity +from .slack import SlackAttachmentEntity, SlackChannelEntity, SlackMessageEntity from .slite import SliteNoteEntity from .stripe import ( StripeBalanceEntity, @@ -335,6 +334,7 @@ TrelloMemberEntity, ) from .web import WebFileEntity +from .wispr import WisprMeetingEntity, WisprNoteEntity from .word import WordDocumentEntity from .zendesk import ( ZendeskAttachmentEntity, @@ -383,12 +383,7 @@ AsanaTaskEntity, AsanaWorkspaceEntity, ], - "attio": [ - AttioListEntity, - AttioNoteEntity, - AttioObjectEntity, - AttioRecordEntity, - ], + "attio": [AttioOriginalEntity], "bitbucket": [ BitbucketCodeFileEntity, BitbucketDirectoryEntity, @@ -466,6 +461,7 @@ ], "github": [ GitHubCodeFileEntity, + GitHubTextEntity, GithubContentEntity, GitHubDirectoryEntity, GitHubFileDeletionEntity, @@ -518,6 +514,7 @@ GoogleDocsDocumentEntity, ], "google_drive": [ + GoogleDriveFolderEntity, GoogleDriveDriveEntity, GoogleDriveFileDeletionEntity, GoogleDriveFileEntity, @@ -548,6 +545,7 @@ LinearAttachmentEntity, LinearCommentEntity, LinearIssueEntity, + LinearLinkedAttachmentEntity, LinearProjectEntity, LinearTeamEntity, LinearUserEntity, @@ -563,6 +561,7 @@ "notion": [ NotionDatabaseEntity, NotionFileEntity, + NotionOriginalEntity, NotionPageEntity, NotionPropertyEntity, ], @@ -651,6 +650,8 @@ SlabTopicEntity, ], "slack": [ + SlackAttachmentEntity, + SlackChannelEntity, SlackMessageEntity, ], "stripe": [ @@ -704,6 +705,7 @@ "web": [ WebFileEntity, ], + "wispr": [WisprMeetingEntity, WisprNoteEntity], "word": [ WordDocumentEntity, ], diff --git a/backend/airweave/platform/entities/_base.py b/backend/airweave/platform/entities/_base.py index 8b3fba968f..95b974db47 100644 --- a/backend/airweave/platform/entities/_base.py +++ b/backend/airweave/platform/entities/_base.py @@ -62,6 +62,13 @@ class AirweaveSystemMetadata(BaseModel): None, description="ID of the sync job this entity belongs to." ) + # Canonical projector-owned metadata. Provider entity fallbacks do not set these. + canonical_record_type: Optional[str] = None + source_created_us: Optional[int] = None + source_updated_us: Optional[int] = None + source_created_known: Optional[int] = None + source_updated_known: Optional[int] = None + # Set during hash computation hash: Optional[str] = Field(None, description="Hash of the content used for change detection.") diff --git a/backend/airweave/platform/entities/attio.py b/backend/airweave/platform/entities/attio.py index 2924d96330..21ec84e087 100644 --- a/backend/airweave/platform/entities/attio.py +++ b/backend/airweave/platform/entities/attio.py @@ -1,11 +1,7 @@ -"""Attio entity schemas. - -Attio is a CRM platform that organizes data into Objects (Companies, People, Deals) -and Lists (custom collections). Each object/list contains Records with custom attributes. -""" +"""Offline search schema for retained Attio originals.""" from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Literal from pydantic import computed_field @@ -13,164 +9,25 @@ from airweave.platform.entities._base import BaseEntity -class AttioObjectEntity(BaseEntity): - """Schema for Attio Object (e.g., Companies, People, Deals).""" - - object_id: str = AirweaveField(..., description="Attio object ID", is_entity_id=True) - name: str = AirweaveField( - ..., description="Display name of the object", is_name=True, embeddable=True - ) - created_at: Optional[datetime] = AirweaveField( - None, description="When the object was created", is_created_at=True - ) - - singular_noun: str = AirweaveField( - ..., description="Singular name of the object (e.g., 'Company')", embeddable=True - ) - plural_noun: str = AirweaveField( - ..., description="Plural name of the object (e.g., 'Companies')", embeddable=True - ) - api_slug: str = AirweaveField(..., description="API slug for the object", embeddable=False) - icon: Optional[str] = AirweaveField( - None, description="Icon representing this object", embeddable=False - ) - - @computed_field(return_type=str) - def web_url(self) -> str: - """Link to the object definition inside Attio.""" - return f"https://app.attio.com/objects/{self.api_slug}" - - -class AttioListEntity(BaseEntity): - """Schema for Attio List.""" +class AttioOriginalEntity(BaseEntity): + """Search text derived from one retained Attio original, without provider reads.""" - list_id: str = AirweaveField(..., description="Attio list ID", is_entity_id=True) - name: str = AirweaveField(..., description="List name", is_name=True, embeddable=True) - created_at: Optional[datetime] = AirweaveField( - None, description="When the list was created", is_created_at=True + native_id: str = AirweaveField(..., description="Native Attio UUID", is_entity_id=True) + original_kind: Literal["object", "record", "list", "entry", "note"] = AirweaveField( + ..., description="Native record kind" ) - - workspace_id: str = AirweaveField( - ..., description="ID of the workspace this list belongs to", embeddable=False + title: str = AirweaveField(..., description="Original label", is_name=True, embeddable=True) + text: str = AirweaveField(..., description="Retained original content", embeddable=True) + content_coverage: str = AirweaveField(..., description="Included and omitted content") + native_url: str | None = AirweaveField(None, description="Verified retained native URL") + created_at: datetime | None = AirweaveField( + None, description="Native creation time", is_created_at=True ) - parent_object: Optional[str] = AirweaveField( - None, description="Parent object type if applicable", embeddable=True + updated_at: datetime | None = AirweaveField( + None, description="Native modification time", is_updated_at=True ) @computed_field(return_type=str) def web_url(self) -> str: - """Link to the list inside Attio.""" - return f"https://app.attio.com/lists/{self.list_id}" - - -class AttioRecordEntity(BaseEntity): - """Schema for Attio Record.""" - - record_id: str = AirweaveField(..., description="Attio record ID", is_entity_id=True) - name: str = AirweaveField(..., description="Record display name", is_name=True, embeddable=True) - created_at: Optional[datetime] = AirweaveField( - None, description="When the record was created", is_created_at=True - ) - updated_at: Optional[datetime] = AirweaveField( - None, description="When the record was last updated", is_updated_at=True - ) - - object_id: Optional[str] = AirweaveField( - None, description="ID/slug of the object this record belongs to", embeddable=False - ) - list_id: Optional[str] = AirweaveField( - None, description="ID of the list this record belongs to", embeddable=False - ) - parent_object_name: Optional[str] = AirweaveField( - None, description="Name of the parent object/list", embeddable=True - ) - - description: Optional[str] = AirweaveField( - None, description="Description of the record", embeddable=True - ) - email_addresses: List[Dict[str, Any]] = AirweaveField( - default_factory=list, - description="Email addresses associated with this record", - embeddable=True, - ) - phone_numbers: List[Dict[str, Any]] = AirweaveField( - default_factory=list, - description="Phone numbers associated with this record", - embeddable=True, - ) - domains: List[str] = AirweaveField( - default_factory=list, description="Domain names (for company records)", embeddable=True - ) - categories: List[str] = AirweaveField( - default_factory=list, description="Categories/tags for this record", embeddable=True - ) - attributes: Dict[str, Any] = AirweaveField( - default_factory=dict, - description="Custom attributes and their values", - embeddable=True, - ) - permalink_url: Optional[str] = AirweaveField( - None, - description="URL to view this record in Attio (if provided by API)", - embeddable=False, - unhashable=True, - ) - - @computed_field(return_type=Optional[str]) - def web_url(self) -> Optional[str]: - """Best-effort link back to the record inside Attio.""" - if self.permalink_url: - return self.permalink_url - if self.object_id: - return f"https://app.attio.com/objects/{self.object_id}/{self.record_id}" - if self.list_id: - return f"https://app.attio.com/lists/{self.list_id}/{self.record_id}" - return None - - -class AttioNoteEntity(BaseEntity): - """Schema for Attio Note.""" - - note_id: str = AirweaveField(..., description="Attio note ID", is_entity_id=True) - name: str = AirweaveField( - ..., description="Note title or preview", is_name=True, embeddable=True - ) - created_at: Optional[datetime] = AirweaveField( - None, description="When the note was created", is_created_at=True - ) - updated_at: Optional[datetime] = AirweaveField( - None, description="When the note was last updated", is_updated_at=True - ) - - parent_record_id: str = AirweaveField( - ..., description="ID of the record this note is attached to", embeddable=False - ) - parent_object: Optional[str] = AirweaveField( - None, description="Type of parent object", embeddable=False - ) - title: Optional[str] = AirweaveField(None, description="Title of the note", embeddable=True) - content: str = AirweaveField(..., description="Content of the note", embeddable=True) - format: Optional[str] = AirweaveField( - None, description="Format of the note (plaintext, markdown, etc.)", embeddable=False - ) - author: Optional[Dict[str, Any]] = AirweaveField( - None, description="User who created this note", embeddable=True - ) - permalink_url: Optional[str] = AirweaveField( - None, - description="URL to view this note in Attio (if provided by API)", - embeddable=False, - unhashable=True, - ) - - @computed_field(return_type=Optional[str]) - def web_url(self) -> Optional[str]: - """Best-effort link back to the note inside Attio.""" - if self.permalink_url: - return self.permalink_url - return f"https://app.attio.com/notes/{self.note_id}" - - -# Note: AttioCommentEntity was removed because the Attio API does not provide -# a way to fetch comments for notes through their public REST API. -# Comments are visible in the Attio UI but not accessible via /v2/threads or any other endpoint. + """Native navigation requires a verified URL; do not synthesize one.""" + return self.native_url or "" diff --git a/backend/airweave/platform/entities/github.py b/backend/airweave/platform/entities/github.py index 051ad2cf76..7733a3b39b 100644 --- a/backend/airweave/platform/entities/github.py +++ b/backend/airweave/platform/entities/github.py @@ -558,3 +558,23 @@ class GitHubFileDeletionEntity(DeletionEntity): def web_url(self) -> str: """Fallback to repository page since deleted files have no direct URLs.""" return f"https://github.com/{self.repo_owner}/{self.repo_name}" + + +class GitHubTextEntity(BaseEntity): + """Searchable native GitHub text with its exact resource kind and content coverage.""" + + original_id: str = AirweaveField( + ..., description="Canonical original identity", is_entity_id=True + ) + title: str = AirweaveField( + ..., description="Native title or resource label", is_name=True, embeddable=True + ) + resource_kind: str = AirweaveField( + ..., description="Native GitHub resource kind", embeddable=True + ) + repository: str = AirweaveField(..., description="Repository full name", embeddable=True) + text: str = AirweaveField(..., description="Retained native text", embeddable=True) + state: str | None = AirweaveField(None, description="Native state", embeddable=True) + content_coverage: str = AirweaveField( + ..., description="Capture content coverage", embeddable=False + ) diff --git a/backend/airweave/platform/entities/google_drive.py b/backend/airweave/platform/entities/google_drive.py index c4ff91a50b..ac6e7e5468 100644 --- a/backend/airweave/platform/entities/google_drive.py +++ b/backend/airweave/platform/entities/google_drive.py @@ -23,7 +23,7 @@ from airweave.platform.entities._base import BaseEntity, Breadcrumb, DeletionEntity, FileEntity from airweave.platform.entities.utils import _determine_file_type_from_mime -_GOOGLE_EXPORT_MAP: Dict[str, tuple[str, str]] = { +GOOGLE_EXPORT_FORMATS: Dict[str, tuple[str, str]] = { "application/vnd.google-apps.document": ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", @@ -190,7 +190,7 @@ def from_api( file_id = data["id"] if mime_type.startswith("application/vnd.google-apps."): - export_mime_type, file_extension = _GOOGLE_EXPORT_MAP.get( + export_mime_type, file_extension = GOOGLE_EXPORT_FORMATS.get( mime_type, ("application/pdf", ".pdf") ) download_url = ( @@ -303,3 +303,16 @@ def web_url(self) -> str: if self.drive_id: return f"https://drive.google.com/drive/folders/{self.drive_id}" return "https://drive.google.com/drive/my-drive" + + +class GoogleDriveFolderEntity(BaseEntity): + """Searchable folder metadata; folders never require a fabricated file body.""" + + folder_id: str = AirweaveField(..., description="Native folder ID", is_entity_id=True) + title: str = AirweaveField(..., description="Folder name", is_name=True, embeddable=True) + description: str = AirweaveField(..., description="Folder description", embeddable=True) + + @computed_field(return_type=str) + def web_url(self) -> str: + """Direct folder locator.""" + return f"https://drive.google.com/drive/folders/{self.folder_id}" diff --git a/backend/airweave/platform/entities/linear.py b/backend/airweave/platform/entities/linear.py index 3ab98dce2a..f344fad3fd 100644 --- a/backend/airweave/platform/entities/linear.py +++ b/backend/airweave/platform/entities/linear.py @@ -555,3 +555,26 @@ class LinearAttachmentEntity(FileEntity): def web_url(self) -> str: """Browser URL for the attachment (falls back to download URL).""" return self.web_url_value or self.url + + +class LinearLinkedAttachmentEntity(BaseEntity): + """Searchable link metadata; never represents retained file content.""" + + attachment_id: str = AirweaveField( + ..., description="Native attachment identity", is_entity_id=True + ) + title: str = AirweaveField( + ..., description="Native attachment title", is_name=True, embeddable=True + ) + issue_identifier: str = AirweaveField( + ..., description="Parent issue identifier", embeddable=True + ) + target_url: str = AirweaveField(..., description="Original linked resource", embeddable=False) + content_coverage: str = AirweaveField( + ..., description="Available content coverage", embeddable=True + ) + + @computed_field(return_type=str) + def web_url(self) -> str: + """Return the original link without pretending its content was fetched.""" + return self.target_url diff --git a/backend/airweave/platform/entities/notion.py b/backend/airweave/platform/entities/notion.py index 752b116269..d8f6c99eae 100644 --- a/backend/airweave/platform/entities/notion.py +++ b/backend/airweave/platform/entities/notion.py @@ -556,3 +556,29 @@ def web_url(self) -> str: if self.web_url_value: return self.web_url_value return self.url + + +class NotionOriginalEntity(BaseEntity): + """Searchable metadata or block-local text from one retained Notion original.""" + + native_id: str = AirweaveField( + ..., description="Native Notion object or property ID.", is_entity_id=True + ) + original_kind: str = AirweaveField(..., description="Native Notion object kind.") + title: str = AirweaveField( + ..., description="Retained title or block label.", is_name=True, embeddable=True + ) + text: str = AirweaveField( + ..., description="Retained metadata or this block's text only.", embeddable=True + ) + native_url: Optional[str] = AirweaveField( + None, description="URL supplied by Notion, when retained." + ) + content_coverage: str = AirweaveField( + ..., description="What this projection includes and omits." + ) + + @computed_field(return_type=str) + def web_url(self) -> str: + """Preserve native navigation without inventing block or child URLs.""" + return self.native_url or "" diff --git a/backend/airweave/platform/entities/outlook_calendar.py b/backend/airweave/platform/entities/outlook_calendar.py index 7167c050de..ebcd1df46a 100644 --- a/backend/airweave/platform/entities/outlook_calendar.py +++ b/backend/airweave/platform/entities/outlook_calendar.py @@ -454,3 +454,69 @@ def web_url(self) -> str: if self.event_web_url: return self.event_web_url return f"https://outlook.office.com/calendar/item/{self.event_id}" + + +class OutlookCalendarMetadataEntity(BaseEntity): + """Owned calendar metadata without invented permission defaults.""" + + native_id: str = AirweaveField(..., description="Native calendar ID.", is_entity_id=True) + title: str = AirweaveField(..., description="Calendar name.", is_name=True, embeddable=True) + owner: Optional[str] = AirweaveField(None, description="Calendar owner.", embeddable=True) + can_edit: Optional[bool] = AirweaveField(None, description="Native edit permission.") + can_view_private_items: Optional[bool] = AirweaveField( + None, description="Native private-item permission." + ) + is_default_calendar: Optional[bool] = AirweaveField(None, description="Native default status.") + + +class OutlookCalendarSearchEventEntity(BaseEntity): + """Searchable native facts; wall times never gain an inferred UTC offset.""" + + native_id: str = AirweaveField(..., description="Native event ID.", is_entity_id=True) + title: str = AirweaveField(..., description="Event subject.", is_name=True, embeddable=True) + calendar_id: str = AirweaveField(..., description="Native owning calendar ID.") + event_type: str = AirweaveField(..., description="Native event kind.", embeddable=True) + start: str = AirweaveField(..., description="Native start wall time and zone.", embeddable=True) + end: str = AirweaveField(..., description="Native end wall time and zone.", embeddable=True) + is_all_day: bool = AirweaveField(..., description="Native all-day status.", embeddable=True) + is_cancelled: bool = AirweaveField( + ..., description="Native cancellation status.", embeddable=True + ) + organizer: Optional[str] = AirweaveField(None, description="Organizer.", embeddable=True) + attendees: List[str] = AirweaveField( + default_factory=list, + description="Participants with native response status.", + embeddable=True, + ) + recurrence: Optional[str] = AirweaveField( + None, description="Native recurrence pattern and range.", embeddable=True + ) + series_master_id: Optional[str] = AirweaveField(None, description="Native recurring-series ID.") + original_start: Optional[str] = AirweaveField( + None, description="Native original occurrence start.", embeddable=True + ) + original_start_timezone: Optional[str] = AirweaveField( + None, description="Original native start zone.", embeddable=True + ) + original_end_timezone: Optional[str] = AirweaveField( + None, description="Original native end zone.", embeddable=True + ) + locations: List[str] = AirweaveField( + default_factory=list, description="Native location names.", embeddable=True + ) + categories: List[str] = AirweaveField( + default_factory=list, description="Native categories.", embeddable=True + ) + show_as: Optional[str] = AirweaveField( + None, description="Native availability.", embeddable=True + ) + web_link: Optional[str] = AirweaveField(None, description="Native Outlook event URL.") + + @computed_field(return_type=Optional[str]) + def web_url(self) -> Optional[str]: + """Expose only the retained native link to shared search result serialization.""" + return self.web_link + + +class OutlookCalendarHtmlEventEntity(OutlookCalendarSearchEventEntity, FileEntity): + """Same event facts plus a disposable HTML input for the shared converter.""" diff --git a/backend/airweave/platform/entities/outlook_mail.py b/backend/airweave/platform/entities/outlook_mail.py index bfea72230a..7e7c71e967 100644 --- a/backend/airweave/platform/entities/outlook_mail.py +++ b/backend/airweave/platform/entities/outlook_mail.py @@ -94,8 +94,8 @@ class OutlookMessageEntity(EmailEntity): description="Message ID from Microsoft Graph.", is_entity_id=True, ) - folder_name: str = AirweaveField( - ..., description="Name of the folder containing this message", embeddable=True + folder_name: Optional[str] = AirweaveField( + None, description="Name of the folder containing this message", embeddable=True ) subject: str = AirweaveField( ..., @@ -106,6 +106,17 @@ class OutlookMessageEntity(EmailEntity): sender: Optional[str] = AirweaveField( None, description="Email address of the sender", embeddable=True ) + folder_id: Optional[str] = AirweaveField(None, description="Native current folder ID") + from_address: Optional[str] = AirweaveField( + None, description="Author address, distinct from the sending agent", embeddable=True + ) + conversation_id: Optional[str] = AirweaveField(None, description="Native conversation ID") + bcc_recipients: List[str] = AirweaveField( + default_factory=list, description="BCC recipients", embeddable=True + ) + reply_to: List[str] = AirweaveField( + default_factory=list, description="Reply-to recipients", embeddable=True + ) to_recipients: List[str] = AirweaveField( default_factory=list, description="Recipients of the message", embeddable=True ) @@ -232,7 +243,9 @@ class OutlookAttachmentEntity(FileEntity): is_name=True, ) message_id: str = AirweaveField(..., description="ID of the message this attachment belongs to") - attachment_id: str = AirweaveField(..., description="Outlook's attachment ID") + attachment_id: str = AirweaveField( + ..., description="Native attachment ID or MIME part path; metadata identifies the scheme" + ) content_type: Optional[str] = AirweaveField(None, description="Content type of the attachment") is_inline: bool = AirweaveField(False, description="Whether this is an inline attachment") content_id: Optional[str] = AirweaveField(None, description="Content ID for inline attachments") diff --git a/backend/airweave/platform/entities/slack.py b/backend/airweave/platform/entities/slack.py index e79b9c2a65..7d2f4617d6 100644 --- a/backend/airweave/platform/entities/slack.py +++ b/backend/airweave/platform/entities/slack.py @@ -8,7 +8,7 @@ from pydantic import computed_field from airweave.platform.entities._airweave_field import AirweaveField -from airweave.platform.entities._base import BaseEntity, Breadcrumb +from airweave.platform.entities._base import BaseEntity, Breadcrumb, FileEntity def _parse_slack_ts(ts_str: Any) -> Optional[datetime]: @@ -132,3 +132,21 @@ def from_api( def web_url(self) -> str: """Permalink for the Slack message.""" return self.web_url_value or self.url or "" + + +class SlackChannelEntity(BaseEntity): + """Captured conversation metadata, without inferring visibility from its name.""" + + channel_id: str = AirweaveField(..., description="Native conversation ID", is_entity_id=True) + title: str = AirweaveField(..., description="Conversation name", is_name=True, embeddable=True) + purpose: str = AirweaveField(..., description="Conversation purpose", embeddable=True) + topic: str = AirweaveField(..., description="Conversation topic", embeddable=True) + + +class SlackAttachmentEntity(FileEntity): + """Retained original file belonging to one captured Slack message.""" + + attachment_key: str = AirweaveField( + ..., description="Message-scoped native file identity", is_entity_id=True + ) + filename: str = AirweaveField(..., description="Native filename or file ID", is_name=True) diff --git a/backend/airweave/platform/entities/stripe.py b/backend/airweave/platform/entities/stripe.py index 6aece31740..42d7acdbbb 100644 --- a/backend/airweave/platform/entities/stripe.py +++ b/backend/airweave/platform/entities/stripe.py @@ -163,8 +163,8 @@ class StripeChargeEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the charge was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="Last activity timestamp for the charge.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="Last activity timestamp for the charge.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -180,12 +180,14 @@ class StripeChargeEntity(BaseEntity): currency: Optional[str] = AirweaveField( None, description="Three-letter ISO currency code", embeddable=True ) - captured: bool = AirweaveField( - False, description="Whether the charge was captured", embeddable=True + captured: Optional[bool] = AirweaveField( + None, description="Whether the charge was captured", embeddable=True ) - paid: bool = AirweaveField(False, description="Whether the charge was paid", embeddable=True) - refunded: bool = AirweaveField( - False, description="Whether the charge was refunded", embeddable=True + paid: Optional[bool] = AirweaveField( + None, description="Whether the charge was paid", embeddable=True + ) + refunded: Optional[bool] = AirweaveField( + None, description="Whether the charge was refunded", embeddable=True ) description: Optional[str] = AirweaveField( None, description="Arbitrary description of the charge", embeddable=True @@ -270,8 +272,8 @@ class StripeCustomerEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the customer was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="Timestamp of the latest update.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="Timestamp of the latest update.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -300,8 +302,8 @@ class StripeCustomerEntity(BaseEntity): description="ID of the default payment source (e.g. card) attached to this customer", embeddable=False, ) - delinquent: bool = AirweaveField( - False, description="Whether the customer has any unpaid/overdue invoices", embeddable=True + delinquent: Optional[bool] = AirweaveField( + None, description="Whether the customer has any unpaid/overdue invoices", embeddable=True ) invoice_prefix: Optional[str] = AirweaveField( None, description="Prefix for the customer's invoices", embeddable=False @@ -383,8 +385,8 @@ class StripeInvoiceEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the invoice was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="When the invoice was last updated.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="When the invoice was last updated.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -417,8 +419,8 @@ class StripeInvoiceEntity(BaseEntity): due_date: Optional[datetime] = AirweaveField( None, description="Date on which payment is due (if applicable)", embeddable=True ) - paid: bool = AirweaveField( - False, description="Whether the invoice has been fully paid", embeddable=True + paid: Optional[bool] = AirweaveField( + None, description="Whether the invoice has been fully paid", embeddable=True ) currency: Optional[str] = AirweaveField( None, description="Three-letter ISO currency code (e.g. 'usd')", embeddable=True @@ -497,8 +499,8 @@ class StripePaymentIntentEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the payment intent was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="Last update timestamp for the payment intent.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="Last update timestamp for the payment intent.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -689,8 +691,8 @@ class StripePayoutEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the payout was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="Last update timestamp for the payout.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="Last update timestamp for the payout.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -852,8 +854,8 @@ class StripeSubscriptionEntity(BaseEntity): created_time: datetime = AirweaveField( ..., description="When the subscription was created.", is_created_at=True ) - updated_time: datetime = AirweaveField( - ..., description="Last update timestamp for the subscription.", is_updated_at=True + updated_time: Optional[datetime] = AirweaveField( + None, description="Last update timestamp for the subscription.", is_updated_at=True ) web_url_value: Optional[str] = AirweaveField( None, @@ -881,8 +883,8 @@ class StripeSubscriptionEntity(BaseEntity): description="End of the current billing period for this subscription", embeddable=True, ) - cancel_at_period_end: bool = AirweaveField( - False, + cancel_at_period_end: Optional[bool] = AirweaveField( + None, description="Whether the subscription will cancel at the end of the current period", embeddable=True, ) diff --git a/backend/airweave/platform/entities/wispr.py b/backend/airweave/platform/entities/wispr.py new file mode 100644 index 0000000000..f2ab126b9c --- /dev/null +++ b/backend/airweave/platform/entities/wispr.py @@ -0,0 +1,48 @@ +"""Search projection of Wispr's captured meeting representations.""" + +from datetime import datetime + +from pydantic import computed_field + +from airweave.platform.entities._airweave_field import AirweaveField +from airweave.platform.entities._base import BaseEntity + + +class WisprMeetingEntity(BaseEntity): + """Selected meeting fields; original response ranges stay in canonical storage.""" + + meeting_id: str = AirweaveField(..., description="Native meeting ID", is_entity_id=True) + title: str = AirweaveField(..., description="Meeting title", is_name=True, embeddable=True) + notes: str = AirweaveField(..., description="Captured markdown notes", embeddable=True) + summary: str = AirweaveField(..., description="Provider summary", embeddable=True) + transcript: str = AirweaveField(..., description="Captured transcript ranges", embeddable=True) + starts_at: datetime | None = AirweaveField( + None, description="Meeting start", is_created_at=True + ) + modified_at: datetime | None = AirweaveField( + None, description="Provider modification", is_updated_at=True + ) + share_link: str | None = AirweaveField( + None, description="Provider notes page", embeddable=False + ) + + @computed_field(return_type=str) + def web_url(self) -> str: + """Use only the provider-supplied sharing link.""" + return self.share_link or "" + + +class WisprNoteEntity(BaseEntity): + """Search projection of a retained scratchpad note, independent of meetings.""" + + note_id: str = AirweaveField(..., description="Native scratchpad note ID", is_entity_id=True) + title: str = AirweaveField(..., description="Note title", is_name=True, embeddable=True) + content: str = AirweaveField(..., description="Captured normalized note text", embeddable=True) + modified_at: datetime | None = AirweaveField( + None, description="Provider modification", is_updated_at=True + ) + + @computed_field(return_type=str) + def web_url(self) -> str: + """The scratchpad response supplies no native URL.""" + return "" diff --git a/backend/airweave/platform/http_client/airweave_client.py b/backend/airweave/platform/http_client/airweave_client.py index 820870cd64..15fbde9c5f 100644 --- a/backend/airweave/platform/http_client/airweave_client.py +++ b/backend/airweave/platform/http_client/airweave_client.py @@ -154,19 +154,10 @@ async def _log_error_response(self, method: str, url: str, response: httpx.Respo if not self._logger: return - try: - # Try to read response body - may already be consumed - response_body = response.text - except Exception: - response_body = "" - + # Error bodies and signed URL queries may carry account data or credentials. self._logger.debug( - f"[AirweaveHttpClient] HTTP {response.status_code} error\n" - f" Source: {self._source_short_name}\n" - f" Method: {method}\n" - f" URL: {url}\n" - f" Response Headers: {dict(response.headers)}\n" - f" Response Body: {response_body}" + "[AirweaveHttpClient] HTTP %s source=%s method=%s host=%s", + response.status_code, self._source_short_name, method, httpx.URL(url).host, ) # Mimic httpx.AsyncClient methods diff --git a/backend/airweave/platform/http_client/bounded_response.py b/backend/airweave/platform/http_client/bounded_response.py new file mode 100644 index 0000000000..c789595dfb --- /dev/null +++ b/backend/airweave/platform/http_client/bounded_response.py @@ -0,0 +1,22 @@ +"""Bound identity-encoded response bodies while owning iterator closure.""" + +from contextlib import aclosing + +import httpx + +from airweave.domains.storage import FileSkippedException + + +async def bounded_response_bytes(response: httpx.Response, maximum: int, *, label: str) -> bytes: + """Caller requests identity encoding; the raw bound is then a decoded byte bound too.""" + if response.is_stream_consumed: + if len(response.content) > maximum: + raise FileSkippedException(f"{label} exceeds size limit", label) + return response.content + body = bytearray() + async with aclosing(response.stream.__aiter__()) as chunks: + async for chunk in chunks: + if len(body) + len(chunk) > maximum: + raise FileSkippedException(f"{label} exceeds size limit", label) + body.extend(chunk) + return bytes(body) diff --git a/backend/airweave/platform/http_client/composio_transport.py b/backend/airweave/platform/http_client/composio_transport.py new file mode 100644 index 0000000000..77e069da93 --- /dev/null +++ b/backend/airweave/platform/http_client/composio_transport.py @@ -0,0 +1,262 @@ +"""Account-bound Composio HTTPX transport; credentials remain with Composio.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Iterable +from contextlib import aclosing + +import httpx +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, JsonValue + +from airweave.domains.auth_provider.exceptions import ( + AuthProviderAuthError, + AuthProviderRateLimitError, + AuthProviderServerError, +) +from airweave.platform.utils.ssrf import validate_url + + +class BinaryData(BaseModel): + """Temporary download supplied by Composio, never a provider credential.""" + + model_config = ConfigDict(extra="ignore") + url: HttpUrl = Field(repr=False) + content_type: str + size: int = Field(ge=0) + expires_at: str + + +class ProxyResponse(BaseModel): + """Documented proxy envelope (binary data takes precedence over JSON data).""" + + model_config = ConfigDict(extra="ignore") + status: int = Field(ge=100, le=599) + data: JsonValue = None + headers: dict[str, str] = Field(default_factory=dict) + binary_data: BinaryData | None = None + + +class ComposioProxyError(httpx.TransportError): + """Invalid proxy protocol or disallowed request, without sensitive payloads.""" + + +class DownloadStream(httpx.AsyncByteStream): + """Own the temporary download response until its consumer closes it.""" + + def __init__(self, response: httpx.Response, max_bytes: int): + """Retain the response and enforce an independent byte ceiling.""" + self.response = response + self.max_bytes = max_bytes + + async def __aiter__(self) -> AsyncIterator[bytes]: + """Yield bytes while enforcing the configured download bound.""" + count = 0 + async with aclosing(self.response.aiter_bytes()) as chunks: + async for chunk in chunks: + count += len(chunk) + if count > self.max_bytes: + raise ComposioProxyError("Proxy download exceeds the file size limit") + yield chunk + + async def aclose(self) -> None: + """Close the underlying download connection.""" + await self.response.aclose() + + +class ComposioTransport(httpx.AsyncBaseTransport): + """Route only this connection's approved hosts through the fixed proxy API.""" + + MAX_BINARY_BYTES = 200 * 1024 * 1024 + + def __init__( + self, + *, + api_key: str, + connected_account_id: str, + allowed_hosts: Iterable[str], + client: httpx.AsyncClient | None = None, + download_transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + """Bind one account; injected clients stay owned by their caller.""" + self._owns_client = client is None + self._owns_download_transport = download_transport is None + self._client = client or httpx.AsyncClient(timeout=180.0, follow_redirects=False) + self._download_transport = download_transport or httpx.AsyncHTTPTransport() + self._api_key = api_key + self._account_id = connected_account_id + self._hosts = frozenset(allowed_hosts) + if not self._account_id or not self._hosts: + raise ValueError("A bound account and provider hosts are required") + + async def aclose(self) -> None: + """Close only clients constructed by this transport.""" + if self._owns_client: + await self._client.aclose() + if self._owns_download_transport: + await self._download_transport.aclose() + + async def _download(self, data: BinaryData) -> httpx.Response: + if data.size > self.MAX_BINARY_BYTES: + raise ComposioProxyError("Proxy download exceeds the file size limit") + url = str(data.url) + for _ in range(6): + parsed = httpx.URL(url) + if parsed.scheme != "https" or parsed.userinfo or parsed.port not in (None, 443): + raise ComposioProxyError("Invalid proxy download URL") + validate_url(url) + # Construct a new request: never inherit client auth, cookies or provider headers. + request = httpx.Request( + "GET", + url, + extensions={ + "timeout": {name: 180.0 for name in ("connect", "read", "write", "pool")} + }, + ) + # Use the public transport interface to avoid HTTPX logging the signed URL. + # This layer owns redirects and streaming, and sends no ambient credentials. + try: + response = await self._download_transport.handle_async_request(request) + except httpx.RequestError: + raise ComposioProxyError("Temporary download request failed") from None + if response.is_redirect: + location = response.headers.get("location") + await response.aclose() + if not location: + raise ComposioProxyError("Proxy download redirect lacks location") + url = str(parsed.join(location)) + continue + if response.is_error: + status = response.status_code + await response.aclose() + raise ComposioProxyError(f"Temporary download returned HTTP {status}") + return response + raise ComposioProxyError("Too many proxy download redirects") + + @staticmethod + def _check_proxy_response(response: httpx.Response, request: httpx.Request) -> None: + """Classify proxy failures independently of upstream provider status.""" + if response.status_code in (401, 403): + raise AuthProviderAuthError( + "Composio project authentication failed", provider_name="composio" + ) + if response.status_code == 429: + try: + wait = float(response.headers.get("retry-after", "30")) + except ValueError: + wait = 30.0 + raise AuthProviderRateLimitError(provider_name="composio", retry_after=wait) + if response.status_code >= 500: + raise AuthProviderServerError( + provider_name="composio", status_code=response.status_code + ) + if response.status_code == 422: + try: + error = response.json().get("error", {}) + except (ValueError, AttributeError): + error = {} + if isinstance(error, dict) and error.get("code") == 1706: + raise AuthProviderAuthError( + "Composio connected account requires reauthorization", provider_name="composio" + ) + if not 200 <= response.status_code < 300: + raise ComposioProxyError( + f"Composio proxy returned HTTP {response.status_code}", request=request + ) + + async def _proxy_request(self, payload: dict) -> httpx.Response: + """Bound encoded attachment JSON before decoding the proxy envelope.""" + limit = ((self.MAX_BINARY_BYTES + 2) // 3) * 4 + 1024 * 1024 + content = bytearray() + try: + async with self._client.stream( + "POST", + "https://backend.composio.dev/api/v3/tools/execute/proxy", + headers={"x-api-key": self._api_key}, + json=payload, + ) as response: + async with aclosing(response.aiter_bytes()) as chunks: + async for chunk in chunks: + if len(content) + len(chunk) > limit: + raise ComposioProxyError( + "Composio response exceeds the file size limit" + ) + content.extend(chunk) + headers = dict(response.headers) + for name in ("content-length", "content-encoding", "transfer-encoding"): + headers.pop(name, None) + return httpx.Response( + response.status_code, + headers=headers, + content=bytes(content), + request=response.request, + ) + except ComposioProxyError: + raise + except httpx.RequestError: + raise AuthProviderServerError( + "Composio proxy request failed", provider_name="composio" + ) from None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + """Forward one provider request and preserve JSON or binary semantics.""" + url = request.url + if ( + url.scheme != "https" + or url.host not in self._hosts + or url.port not in (None, 443) + or url.userinfo + ): + raise ComposioProxyError("Provider URL outside the bound source", request=request) + parameters = [ + {"name": name, "value": value, "type": "header"} + for name, value in request.headers.multi_items() + if name.lower() + not in {"authorization", "host", "cookie", "content-length", "x-api-key"} + ] + payload = { + "connected_account_id": self._account_id, + "endpoint": str(url), + "method": request.method, + "parameters": parameters, + } + body = await request.aread() + if body: + try: + payload["body"] = json.loads(body) + except (ValueError, UnicodeDecodeError) as exc: + raise ComposioProxyError( + "Proxy requires a JSON request body", request=request + ) from exc + response = await self._proxy_request(payload) + self._check_proxy_response(response, request) + try: + envelope = ProxyResponse.model_validate(response.json()) + except ValueError: + raise ComposioProxyError( + "Invalid Composio response envelope", request=request + ) from None + headers = httpx.Headers(envelope.headers) + for name in ("content-length", "content-encoding", "transfer-encoding", "set-cookie"): + headers.pop(name, None) + if envelope.binary_data is not None and 200 <= envelope.status < 300: + data = envelope.binary_data + headers["content-type"] = data.content_type + if request.method == "HEAD": + headers["content-length"] = str(data.size) + return httpx.Response( + envelope.status, headers=headers, content=b"", request=request + ) + downloaded = await self._download(data) + return httpx.Response( + envelope.status, + headers=headers, + stream=DownloadStream(downloaded, self.MAX_BINARY_BYTES), + request=request, + ) + if isinstance(envelope.data, str): + content = envelope.data.encode("utf-8") + else: + content = json.dumps(envelope.data, ensure_ascii=False).encode("utf-8") + headers["content-type"] = "application/json" + return httpx.Response(envelope.status, headers=headers, content=content, request=request) diff --git a/backend/airweave/platform/sources/retry_helpers.py b/backend/airweave/platform/http_client/retry_helpers.py similarity index 89% rename from backend/airweave/platform/sources/retry_helpers.py rename to backend/airweave/platform/http_client/retry_helpers.py index 88ca044884..eca7f3a21a 100644 --- a/backend/airweave/platform/sources/retry_helpers.py +++ b/backend/airweave/platform/http_client/retry_helpers.py @@ -6,11 +6,13 @@ """ import logging +import math from typing import Callable import httpx from tenacity import retry_if_exception, wait_exponential +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.sources.exceptions import ( SourceRateLimitError, SourceServerError, @@ -120,16 +122,23 @@ async def _get_with_auth(self, url, params=None): ... ) -def wait_rate_limit_with_backoff(retry_state) -> float: +def wait_rate_limit_with_backoff( + retry_state, *, max_rate_limit_wait: float | None = 120.0 +) -> float: """Wait strategy: Retry-After for rate limits, exponential backoff for transients. Handles both raw ``httpx.HTTPStatusError`` (429) and domain - ``SourceRateLimitError`` / ``SourceServerError``. + ``SourceRateLimitError`` / ``SourceServerError`` and managed-provider throttling. + Set ``max_rate_limit_wait=None`` to honor the entire provider delay. """ exception = retry_state.outcome.exception() - if isinstance(exception, SourceRateLimitError): - return min(max(exception.retry_after, 1.0), 120.0) + def bounded_wait(seconds: float) -> float: + seconds = max(seconds, 1.0) if math.isfinite(seconds) else 30.0 + return min(seconds, max_rate_limit_wait) if max_rate_limit_wait is not None else seconds + + if isinstance(exception, (SourceRateLimitError, AuthProviderRateLimitError)): + return bounded_wait(exception.retry_after) if isinstance(exception, SourceServerError): return wait_exponential(multiplier=1, min=2, max=30)(retry_state) @@ -140,7 +149,7 @@ def wait_rate_limit_with_backoff(retry_state) -> float: try: wait_seconds = float(retry_after) wait_seconds = max(wait_seconds, 1.0) - return min(wait_seconds, 120.0) + return bounded_wait(wait_seconds) except (ValueError, TypeError): pass return wait_exponential(multiplier=1, min=2, max=30)(retry_state) diff --git a/backend/airweave/platform/sources/__init__.py b/backend/airweave/platform/sources/__init__.py index 0dc48be828..b5edfeb8ca 100644 --- a/backend/airweave/platform/sources/__init__.py +++ b/backend/airweave/platform/sources/__init__.py @@ -62,6 +62,7 @@ from .timed import TimedSource from .todoist import TodoistSource from .trello import TrelloSource +from .wispr import WisprSource from .word import WordSource from .zendesk import ZendeskSource from .zoho_crm import ZohoCRMSource @@ -129,6 +130,7 @@ TimedSource, TodoistSource, TrelloSource, + WisprSource, WordSource, ZendeskSource, ZoomSource, diff --git a/backend/airweave/platform/sources/_base.py b/backend/airweave/platform/sources/_base.py index 054ec2f818..f83c4826db 100644 --- a/backend/airweave/platform/sources/_base.py +++ b/backend/airweave/platform/sources/_base.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from airweave.domains.access_control.schemas import MembershipTuple + from airweave.domains.entities.canonical.page_source import CanonicalPageSource from pydantic import BaseModel @@ -41,6 +42,8 @@ class BaseSource: node_selections) are passed as params to ``generate_entities()``. """ + _capture_page_source: CanonicalPageSource | None = None + # Identity (set by @source decorator — required) is_source: ClassVar[bool] = False source_name: ClassVar[str] = "" @@ -104,6 +107,15 @@ def http_client(self) -> AirweaveHttpClient: """Pre-built HTTP client with rate limiting.""" return self._http_client + @property + def capture_page_source(self) -> CanonicalPageSource | None: + """The active page adapter; source identity and authentication stay on this object.""" + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + + if self._capture_page_source is not None: + return self._capture_page_source + return self if isinstance(self, CanonicalPageSource) else None + # ------------------------------------------------------------------ # Auth convenience methods # ------------------------------------------------------------------ diff --git a/backend/airweave/platform/sources/airtable.py b/backend/airweave/platform/sources/airtable.py index 671e7d03d0..aba9cef64a 100644 --- a/backend/airweave/platform/sources/airtable.py +++ b/backend/airweave/platform/sources/airtable.py @@ -28,12 +28,12 @@ AirtableUserEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType _API = "https://api.airtable.com/v0" diff --git a/backend/airweave/platform/sources/apollo.py b/backend/airweave/platform/sources/apollo.py index e15ed1fbea..13b6acd397 100644 --- a/backend/airweave/platform/sources/apollo.py +++ b/backend/airweave/platform/sources/apollo.py @@ -26,12 +26,12 @@ ApolloSequenceEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod APOLLO_BASE_URL = "https://api.apollo.io/api/v1" diff --git a/backend/airweave/platform/sources/asana.py b/backend/airweave/platform/sources/asana.py index 76317764a5..2a1f407482 100644 --- a/backend/airweave/platform/sources/asana.py +++ b/backend/airweave/platform/sources/asana.py @@ -32,12 +32,12 @@ AsanaWorkspaceEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType _API = "https://app.asana.com/api/1.0" diff --git a/backend/airweave/platform/sources/attio.py b/backend/airweave/platform/sources/attio.py index 76258f05b7..f0f4e29e48 100644 --- a/backend/airweave/platform/sources/attio.py +++ b/backend/airweave/platform/sources/attio.py @@ -1,47 +1,55 @@ -"""Attio source implementation. - -Attio is a flexible CRM platform. We extract: -- Objects (Companies, People, Deals, etc.) -- Lists (Custom collections) -- Records (Individual entries in objects/lists) -- Notes (Attached to records) - -Note: Comments are not supported as the Attio API does not expose them via REST API. -""" +"""Attio originals with durable page progress and explicit discovery-only coverage.""" from __future__ import annotations -from typing import Any, AsyncGenerator, Dict, List, Optional +import hashlib +import json +from collections.abc import AsyncGenerator +from datetime import datetime, timezone +from functools import partial +from uuid import UUID -from tenacity import retry, stop_after_attempt +from pydantic import JsonValue +from tenacity import retry, retry_if_exception_type, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import ( - SourceEntityNotFoundError, - SourceError, +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import CapturePage +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceAuthError +from airweave.domains.sources.token_providers.credential import DirectCredentialProvider +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, ) -from airweave.domains.sources.token_providers.protocol import AuthProviderKind, SourceAuthProvider from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.auth import AttioAuthConfig from airweave.platform.configs.config import AttioConfig from airweave.platform.decorators import source -from airweave.platform.entities._base import BaseEntity, Breadcrumb -from airweave.platform.entities.attio import ( - AttioListEntity, - AttioNoteEntity, - AttioObjectEntity, - AttioRecordEntity, -) +from airweave.platform.entities._base import BaseEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.attio_models import ( + AttioNative, + AttioPage, + AttioPrincipal, + AttioProgress, +) +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod _API = "https://api.attio.com/v2" @@ -59,7 +67,20 @@ rate_limit_level=RateLimitLevel.ORG, ) class AttioSource(BaseSource): - """Attio source connector — syncs objects, lists, records, and notes.""" + """Keep CRM records, list memberships and notes as separate native originals. + + Offset pagination is not a snapshot. Every scope is discovery-only, so missing + rows never authorize deletion. Exact-read and webhook qualification remains + required before this connector can claim complete synchronization. + """ + + canonical_record_types = ("object", "record", "list", "entry", "note") + canonical_container_parents = {"record": "object", "entry": "list", "note": "record"} + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Bind the engine's existing durable cycle to the attested workspace.""" + return self._cycle_configuration @classmethod async def create( @@ -70,475 +91,213 @@ async def create( http_client: AirweaveHttpClient, config: AttioConfig, ) -> AttioSource: - """Create a new Attio source instance.""" + """Attest account identity without extracting managed credentials.""" instance = cls(auth=auth, logger=logger, http_client=http_client) - if auth.provider_kind == AuthProviderKind.CREDENTIAL: - instance._api_key = auth.credentials.api_key - else: - instance._api_key = await auth.get_token() - return instance - - # ------------------------------------------------------------------ - # HTTP - # ------------------------------------------------------------------ - - def _headers(self) -> Dict[str, str]: - """Build request headers with Attio bearer token.""" - return { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "Accept": "application/json", + instance.config = config + await instance.validate() + fingerprint = { + "version": 1, + "workspace": str(config.workspace_id), + "principal": instance.principal.model_dump(mode="json"), + "kinds": cls.canonical_record_types, } - - @retry( - stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, - reraise=True, - ) - async def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Authenticated GET with retry on 429/5xx/timeout.""" - response = await self.http_client.get( - url, headers=self._headers(), params=params, timeout=20.0 - ) - raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + instance._cycle_configuration = CycleConfiguration.from_source( + fingerprint=hashlib.sha256( + json.dumps(fingerprint, sort_keys=True).encode() + ).hexdigest(), + record_types=cls.canonical_record_types, + container_parents=cls.canonical_container_parents, + completion_policies={kind: "discovery_only" for kind in cls.canonical_record_types}, ) - return response.json() + return instance + + async def _headers(self, *, refresh: bool = False) -> dict[str, str]: + if isinstance(self.auth, DirectCredentialProvider): + credentials = AttioAuthConfig.model_validate(self.auth.credentials.model_dump()) + return {"Authorization": f"Bearer {credentials.api_key}"} + return await authorization_headers(self.auth, refresh=refresh) @retry( stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), reraise=True, ) - async def _post(self, url: str, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Authenticated POST with retry on 429/5xx/timeout.""" - response = await self.http_client.post( - url, headers=self._headers(), json=json_data or {}, timeout=20.0 - ) + async def _request( + self, + path: str, + *, + body: dict[str, JsonValue] | None = None, + params: dict[str, str | int] | None = None, + ) -> dict[str, JsonValue]: + headers = await self._headers() + + async def send(): + if body is not None: + return await self.http_client.post(_API + path, headers=headers, json=body) + return await self.http_client.get(_API + path, headers=headers, params=params) + + response = await send() + if response.status_code == 401 and self.auth.supports_refresh: + headers = await self._headers(refresh=True) + response = await send() + # In particular, 404 merge_in_progress must never become an empty page. raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + response, source_short_name="attio", token_provider_kind=self.auth.provider_kind ) return response.json() - # ------------------------------------------------------------------ - # Pagination - # ------------------------------------------------------------------ - - async def _paginate_get( - self, url: str, data_key: str = "data", limit: int = 100 - ) -> AsyncGenerator[Dict[str, Any], None]: - """Paginate through a GET endpoint using offset/limit. Yields items.""" - offset = 0 - while True: - params = {"offset": offset, "limit": limit} - data = await self._get(url, params) - items = data.get(data_key, []) - for item in items: - yield item - if len(items) < limit: - break - offset += limit - - async def _paginate_post( - self, url: str, data_key: str = "data", limit: int = 100 - ) -> AsyncGenerator[Dict[str, Any], None]: - """Paginate through a POST query endpoint using offset/limit. Yields items.""" - offset = 0 - while True: - body = {"offset": offset, "limit": limit} - data = await self._post(url, body) - items = data.get(data_key, []) - for item in items: - yield item - if len(items) < limit: - break - offset += limit - - # ------------------------------------------------------------------ - # Entity generators - # ------------------------------------------------------------------ - - async def _generate_objects(self) -> AsyncGenerator[AttioObjectEntity, None]: - """Generate Attio Object entities (Companies, People, Deals, etc.).""" - self.logger.info("Fetching Attio objects...") - async for obj in self._paginate_get(f"{_API}/objects"): - object_id = obj.get("id", {}).get("object_id") - if not object_id: - continue - - singular_noun = obj.get("singular_noun", "") - display_name = singular_noun or object_id - - yield AttioObjectEntity( - object_id=object_id, - breadcrumbs=[], - name=display_name, - created_at=obj.get("created_at"), - singular_noun=display_name, - plural_noun=obj.get("plural_noun", display_name + "s"), - api_slug=obj.get("api_slug", object_id), - icon=obj.get("icon"), - ) - - async def _generate_lists(self) -> AsyncGenerator[AttioListEntity, None]: - """Generate Attio List entities.""" - self.logger.info("Fetching Attio lists...") - async for lst in self._paginate_get(f"{_API}/lists"): - list_id = lst.get("id", {}).get("list_id") - if not list_id: - continue - - parent_object = lst.get("parent_object") - if isinstance(parent_object, list): - parent_object = ", ".join(parent_object) if parent_object else None - - yield AttioListEntity( - list_id=list_id, - breadcrumbs=[], - name=lst.get("name", list_id), - created_at=lst.get("created_at"), - workspace_id=lst.get("workspace_id", ""), - parent_object=parent_object, - ) - - async def _generate_records_for_object( - self, - object_slug: str, - object_name: str, - object_breadcrumb: Breadcrumb, - ) -> AsyncGenerator[AttioRecordEntity, None]: - """Generate record entities for a specific object.""" - self.logger.debug(f"Fetching records for object: {object_slug}") - url = f"{_API}/objects/{object_slug}/records/query" - - try: - async for record in self._paginate_post(url): - entity = self._build_object_record( - record, object_slug, object_name, object_breadcrumb - ) - if entity: - yield entity - except SourceEntityNotFoundError: - self.logger.warning(f"Object {object_slug} not found or not accessible, skipping") - - async def _generate_records_for_list( - self, - list_id: str, - list_name: str, - list_breadcrumb: Breadcrumb, - ) -> AsyncGenerator[AttioRecordEntity, None]: - """Generate record entities for a specific list.""" - self.logger.debug(f"Fetching records for list: {list_id}") - url = f"{_API}/lists/{list_id}/records/query" - - try: - async for record in self._paginate_post(url): - entity = self._build_list_record(record, list_id, list_name, list_breadcrumb) - if entity: - yield entity - except SourceEntityNotFoundError: - self.logger.debug(f"List {list_id} not found or not accessible, skipping") - - async def _generate_notes_for_record( - self, - parent_object_or_list_id: str, - record_id: str, - record_breadcrumbs: List[Breadcrumb], - ) -> AsyncGenerator[AttioNoteEntity, None]: - """Generate note entities for a specific record.""" - url = f"{_API}/notes" - params = { - "parent_object": parent_object_or_list_id, - "parent_record_id": record_id, - "limit": 50, - } - - try: - data = await self._get(url, params=params) - except SourceEntityNotFoundError: - return - except SourceError as exc: - self.logger.warning(f"Error fetching notes for record {record_id}: {exc}") - return - - for note in data.get("data", []): - note_id = note.get("id", {}).get("note_id") - if not note_id: - continue - - title = note.get("title") - content = note.get("content", "") - if title: - note_name = title - else: - note_name = content[:50] + "..." if len(content) > 50 else content - if not note_name: - note_name = f"Note {note_id}" - - yield AttioNoteEntity( - note_id=note_id, - breadcrumbs=record_breadcrumbs, - name=note_name, - created_at=note.get("created_at"), - updated_at=note.get("updated_at"), - parent_record_id=record_id, - parent_object=parent_object_or_list_id, - title=title, - content=content, - format=note.get("format"), - author=note.get("author"), - permalink_url=None, + async def validate(self) -> None: + """HTTP200 with active=false is revoked access, never identity evidence.""" + principal = AttioPrincipal.model_validate(await self._request("/self")) + if not principal.active: + raise SourceAuthError( + "Attio token is inactive; reconnect the account", + source_short_name="attio", + status_code=200, + token_provider_kind=self.auth.provider_kind, ) - - # ------------------------------------------------------------------ - # Record builders (extract attribute parsing from generator loops) - # ------------------------------------------------------------------ - - def _build_object_record( # noqa: C901 - self, - record: Dict[str, Any], - object_slug: str, - object_name: str, - breadcrumb: Breadcrumb, - ) -> Optional[AttioRecordEntity]: - """Build a record entity from raw API data for an object.""" - record_id = record.get("id", {}).get("record_id") - if not record_id or record.get("deleted_at"): - return None - - values = record.get("values", {}) - name = None - description = None - email_addresses: List[Dict[str, Any]] = [] - phone_numbers: List[Dict[str, Any]] = [] - domains: List[str] = [] - categories: List[str] = [] - attributes: Dict[str, Any] = {} - - for attr_key, attr_values in values.items(): - if not attr_values: - continue - - first_val = attr_values[0] if isinstance(attr_values, list) else attr_values - key_lower = attr_key.lower() - - if "name" in key_lower or "title" in key_lower: - name = ( - (first_val.get("value") or first_val.get("text")) - if isinstance(first_val, dict) - else str(first_val) - ) - elif "description" in key_lower or "notes" in key_lower: - description = ( - (first_val.get("value") or first_val.get("text")) - if isinstance(first_val, dict) - else str(first_val) - ) - elif "email" in key_lower: - email_addresses = [v for v in attr_values if isinstance(v, dict)] - elif "phone" in key_lower: - phone_numbers = [v for v in attr_values if isinstance(v, dict)] - elif "domain" in key_lower: - domains = [ - v.get("domain") if isinstance(v, dict) else v - for v in attr_values - if v is not None - and (isinstance(v, dict) and v.get("domain") or isinstance(v, str)) - ] - elif "category" in key_lower or "tag" in key_lower: - categories = [ - v.get("value") if isinstance(v, dict) else v - for v in attr_values - if v is not None - and (isinstance(v, dict) and v.get("value") or isinstance(v, str)) - ] - attributes[attr_key] = attr_values - - return AttioRecordEntity( - record_id=record_id, - breadcrumbs=[breadcrumb], - name=name or record_id, - created_at=record.get("created_at"), - updated_at=record.get("updated_at"), - object_id=object_slug, - list_id=None, - parent_object_name=object_name, - description=description, - email_addresses=email_addresses, - phone_numbers=phone_numbers, - domains=domains, - categories=categories, - attributes=attributes, - permalink_url=None, + if principal.workspace_id != self.config.workspace_id: + raise ValueError("Attio workspace identity does not match") + self.principal = principal + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Use provider-global UUIDs while retaining the full canonical parent.""" + if self.canonical_container_parents.get(record_type) != parent.identity.record_type: + raise ValueError("Unsupported Attio parent relationship") + return CompletedScope( + record_type=record_type, container_id=parent.identity.native_id, parent=parent.identity ) - def _build_list_record( # noqa: C901 + async def capture_page( self, - record: Dict[str, Any], - list_id: str, - list_name: str, - breadcrumb: Breadcrumb, - ) -> Optional[AttioRecordEntity]: - """Build a record entity from raw API data for a list.""" - record_id = record.get("id", {}).get("record_id") - if not record_id or record.get("deleted_at"): - return None - - values = record.get("values", {}) - name = None - description = None - attributes: Dict[str, Any] = {} - - for attr_key, attr_values in values.items(): - if not attr_values: - continue - - first_val = attr_values[0] if isinstance(attr_values, list) else attr_values - key_lower = attr_key.lower() - - if "name" in key_lower or "title" in key_lower: - name = ( - (first_val.get("value") or first_val.get("text")) - if isinstance(first_val, dict) - else str(first_val) + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Fetch one atomic page, retaining raw native fields and a resumable offset.""" + progress = AttioProgress.model_validate(continuation.value) + kind = scope.record_type + limit = 50 if kind == "note" else 500 + root = kind in {"object", "list"} + object_slug: str | None = None + if root: + if scope.container_id is not None or scope.parent is not None or progress.offset: + raise ValueError("Invalid Attio root scope") + data = await self._request("/objects" if kind == "object" else "/lists") + else: + if parent is None or scope != self.child_scope(parent, kind): + raise ValueError("Attio child scope requires its exact captured parent") + UUID(scope.container_id) + if kind == "record": + data = await self._request( + f"/objects/{scope.container_id}/records/query", + body={"limit": limit, "offset": progress.offset}, ) - elif "description" in key_lower: - description = ( - (first_val.get("value") or first_val.get("text")) - if isinstance(first_val, dict) - else str(first_val) + elif kind == "entry": + data = await self._request( + f"/lists/{scope.container_id}/entries/query", + body={"limit": limit, "offset": progress.offset}, ) - attributes[attr_key] = attr_values - - return AttioRecordEntity( - record_id=record_id, - breadcrumbs=[breadcrumb], - name=name or record_id, - created_at=record.get("created_at"), - updated_at=record.get("updated_at"), - object_id=None, - list_id=list_id, - parent_object_name=list_name, - description=description, - email_addresses=[], - phone_numbers=[], - domains=[], - categories=[], - attributes=attributes, - permalink_url=None, + elif kind == "note": + object_id = parent.identity.container_id + UUID(object_id) + object_response = await self._request(f"/objects/{object_id}") + native_object = AttioNative.model_validate(object_response.get("data")) + if ( + native_object.id.workspace_id != self.config.workspace_id + or str(native_object.id.object_id) != object_id + or not native_object.api_slug + ): + raise ValueError("Attio note parent object identity is invalid") + object_slug = native_object.api_slug + data = await self._request( + "/notes", + params={ + "parent_object": object_id, + "parent_record_id": scope.container_id, + "limit": limit, + "offset": progress.offset, + }, + ) + else: + raise ValueError("Unsupported Attio scope") + page = AttioPage.model_validate(data) + if len(page.data) > limit: + raise ValueError("Attio returned more rows than requested") + records = tuple(self._capture(scope, raw, parent, object_slug) for raw in page.data) + ids = tuple(record.identity.native_id for record in records) + if len(set(ids)) != len(ids) or set(ids).intersection(progress.previous_ids): + raise ValueError("Attio offset page repeated identities; restart discovery") + return CapturePage( + records=records, + final=root or len(records) < limit, + continuation=ScanContinuation( + value=AttioProgress( + offset=progress.offset + len(records), previous_ids=ids + ).model_dump(mode="json") + ), ) - # ------------------------------------------------------------------ - # Orchestration helpers - # ------------------------------------------------------------------ - - async def _yield_record_with_notes( + def _capture( self, - record: AttioRecordEntity, - parent_slug_or_id: str, - parent_breadcrumb: Breadcrumb, - ) -> AsyncGenerator[BaseEntity, None]: - """Yield a record entity followed by its notes.""" - yield record - - record_breadcrumbs = [ - parent_breadcrumb, - Breadcrumb( - entity_id=record.record_id, - name=record.name, - entity_type="AttioRecordEntity", + scope: CompletedScope, + raw: dict[str, JsonValue], + parent: SourceRecord | None, + object_slug: str | None = None, + ) -> CaptureRecord: + item = AttioNative.model_validate(raw) + if item.id.workspace_id != self.config.workspace_id: + raise ValueError("Attio returned a different workspace") + kind = scope.record_type + identity = { + "object": item.id.object_id, + "record": item.id.record_id, + "list": item.id.list_id, + "entry": item.id.entry_id, + "note": item.id.note_id, + }.get(kind) + if identity is None: + raise ValueError("Attio omitted the native identity") + if kind == "record" and str(item.id.object_id) != scope.container_id: + raise ValueError("Attio record belongs to a different object") + if kind == "entry" and ( + str(item.id.list_id) != scope.container_id + or item.parent_record_id is None + or not item.parent_object + ): + raise ValueError("Attio entry has an invalid list or record reference") + if kind == "note" and ( + parent is None + or str(item.parent_record_id) != scope.container_id + or item.parent_object not in {parent.identity.container_id, object_slug} + ): + raise ValueError("Attio note belongs to a different object or record") + return CaptureRecord( + identity=RecordIdentity( + record_type=kind, native_id=str(identity), container_id=scope.container_id ), - ] - - try: - async for note in self._generate_notes_for_record( - parent_slug_or_id, record.record_id, record_breadcrumbs - ): - yield note - except Exception as e: - self.logger.warning( - f"Error fetching notes for record {record.record_id}: {e}", - exc_info=True, - ) + parent=scope.parent, + payload=raw, + source_created_at=item.created_at, + observed_at=datetime.now(timezone.utc), + ) - # ------------------------------------------------------------------ - # Main entry point - # ------------------------------------------------------------------ + async def confirm_absent(self, record: SourceRecord) -> None: + """Offset omissions and ambiguous404 responses cannot certify deletion.""" + raise ValueError( + "Attio discovery cannot confirm absence without qualified provider evidence" + ) - async def generate_entities( # noqa: C901 + async def generate_entities( self, *, cursor: SyncCursor | None = None, files: FileService | None = None, node_selections: list[NodeSelectionData] | None = None, ) -> AsyncGenerator[BaseEntity, None]: - """Generate all entities from Attio.""" - self.logger.info("Starting Attio sync...") - - object_map: Dict[str, AttioObjectEntity] = {} - async for obj in self._generate_objects(): - yield obj - object_map[obj.object_id] = obj - - list_map: Dict[str, AttioListEntity] = {} - async for lst in self._generate_lists(): - yield lst - list_map[lst.list_id] = lst - - for object_id, obj in object_map.items(): - object_breadcrumb = Breadcrumb( - entity_id=object_id, name=obj.name, entity_type="AttioObjectEntity" - ) - - records: List[AttioRecordEntity] = [] - async for record in self._generate_records_for_object( - obj.api_slug, obj.singular_noun, object_breadcrumb - ): - records.append(record) - - async def _object_record_worker(record, _obj=obj, _bc=object_breadcrumb): - async for entity in self._yield_record_with_notes(record, _obj.api_slug, _bc): - yield entity - - async for entity in self.process_entities_concurrent( - items=records, - worker=_object_record_worker, - batch_size=10, - preserve_order=False, - stop_on_error=False, - ): - yield entity - - for list_id, lst in list_map.items(): - list_breadcrumb = Breadcrumb( - entity_id=list_id, name=lst.name, entity_type="AttioListEntity" - ) - - records = [] - async for record in self._generate_records_for_list(list_id, lst.name, list_breadcrumb): - records.append(record) - - async def _list_record_worker(record, _list_id=list_id, _bc=list_breadcrumb): - async for entity in self._yield_record_with_notes(record, _list_id, _bc): - yield entity - - async for entity in self.process_entities_concurrent( - items=records, - worker=_list_record_worker, - batch_size=10, - preserve_order=False, - stop_on_error=False, - ): - yield entity - - self.logger.info("Attio sync completed") - - async def validate(self) -> None: - """Verify credentials by pinging the Attio API.""" - await self._get(f"{_API}/objects", params={"limit": 1}) + """Canonical page capture replaces the legacy lossy entity generator.""" + raise NotImplementedError("Attio requires canonical page capture") + yield # pragma: no cover diff --git a/backend/airweave/platform/sources/attio_models.py b/backend/airweave/platform/sources/attio_models.py new file mode 100644 index 0000000000..345c749d59 --- /dev/null +++ b/backend/airweave/platform/sources/attio_models.py @@ -0,0 +1,57 @@ +"""Attio native response boundaries and bounded durable page progress.""" + +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, StrictBool + + +class AttioModel(BaseModel): + """Validate consumed fields while preserving raw payloads separately.""" + + model_config = ConfigDict(extra="ignore") + + +class AttioPrincipal(AttioModel): + """Native token identity; active is the only provider-guaranteed field.""" + + active: StrictBool + workspace_id: UUID | None = None + token_level: Literal["workspace", "user"] | None = None + authorized_by_workspace_member_id: UUID | None = None + + +class AttioIdentity(AttioModel): + """Native composite IDs, validated by record kind at acquisition.""" + + workspace_id: UUID + object_id: UUID | None = None + list_id: UUID | None = None + record_id: UUID | None = None + entry_id: UUID | None = None + note_id: UUID | None = None + + +class AttioNative(AttioModel): + """Identity and provenance shared by native CRM representations.""" + + id: AttioIdentity + created_at: AwareDatetime | None = None + parent_record_id: UUID | None = None + parent_object: str | None = None + api_slug: str | None = None + + +class AttioPage(AttioModel): + """A bounded native page; overflow fails instead of truncating.""" + + data: list[dict[str, JsonValue]] = Field(max_length=500) + + +class AttioProgress(BaseModel): + """Durable offset plus the previous page IDs for overlap detection.""" + + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + offset: int = Field(default=0, ge=0) + previous_ids: tuple[str, ...] = Field(default=(), max_length=500) diff --git a/backend/airweave/platform/sources/bitbucket.py b/backend/airweave/platform/sources/bitbucket.py index 7552afbbd7..6febe44fc7 100644 --- a/backend/airweave/platform/sources/bitbucket.py +++ b/backend/airweave/platform/sources/bitbucket.py @@ -27,12 +27,12 @@ BitbucketWorkspaceEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.platform.utils.file_extensions import ( get_language_for_extension, is_text_file, diff --git a/backend/airweave/platform/sources/box.py b/backend/airweave/platform/sources/box.py index bde1d66e6b..0676d97f84 100644 --- a/backend/airweave/platform/sources/box.py +++ b/backend/airweave/platform/sources/box.py @@ -27,12 +27,12 @@ BoxUserEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/clickup.py b/backend/airweave/platform/sources/clickup.py index da6d27aad9..04554c9e7b 100644 --- a/backend/airweave/platform/sources/clickup.py +++ b/backend/airweave/platform/sources/clickup.py @@ -31,12 +31,12 @@ _parse_clickup_ts, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/coda.py b/backend/airweave/platform/sources/coda.py index 41a20391d4..1aa3b5b76b 100644 --- a/backend/airweave/platform/sources/coda.py +++ b/backend/airweave/platform/sources/coda.py @@ -32,12 +32,12 @@ _parse_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType CODA_API_BASE = "https://coda.io/apis/v1" diff --git a/backend/airweave/platform/sources/confluence.py b/backend/airweave/platform/sources/confluence.py index 8dd486d29e..cd969b8b67 100644 --- a/backend/airweave/platform/sources/confluence.py +++ b/backend/airweave/platform/sources/confluence.py @@ -41,12 +41,12 @@ ConfluenceSpaceEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType ATLASSIAN_ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" diff --git a/backend/airweave/platform/sources/dropbox.py b/backend/airweave/platform/sources/dropbox.py index 75c01d5b45..6515ee0132 100644 --- a/backend/airweave/platform/sources/dropbox.py +++ b/backend/airweave/platform/sources/dropbox.py @@ -27,12 +27,12 @@ DropboxFolderEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/fireflies.py b/backend/airweave/platform/sources/fireflies.py index 163e3dcec6..b3b6c7fd98 100644 --- a/backend/airweave/platform/sources/fireflies.py +++ b/backend/airweave/platform/sources/fireflies.py @@ -26,12 +26,12 @@ from airweave.platform.entities._base import BaseEntity from airweave.platform.entities.fireflies import FirefliesTranscriptEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod FIREFLIES_GRAPHQL_URL = "https://api.fireflies.ai/graphql" diff --git a/backend/airweave/platform/sources/freshdesk.py b/backend/airweave/platform/sources/freshdesk.py index cd8cebcba8..61d0697bfa 100644 --- a/backend/airweave/platform/sources/freshdesk.py +++ b/backend/airweave/platform/sources/freshdesk.py @@ -33,12 +33,12 @@ FreshdeskTicketEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod diff --git a/backend/airweave/platform/sources/github.py b/backend/airweave/platform/sources/github.py index 4124decb0b..80d1410707 100644 --- a/backend/airweave/platform/sources/github.py +++ b/backend/airweave/platform/sources/github.py @@ -1,51 +1,67 @@ -r"""GitHub source implementation for syncing repositories, directories, and code files.""" +"""Selected GitHub repositories captured through the existing durable page engine.""" from __future__ import annotations -import base64 +import hashlib +import json import mimetypes -from pathlib import Path -from typing import Any, AsyncGenerator, Dict, List, Optional +from collections.abc import AsyncGenerator +from datetime import datetime, timezone +from urllib.parse import quote -from tenacity import retry, stop_after_attempt +from pydantic import JsonValue from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import SourceAuthError -from airweave.domains.sources.token_providers.protocol import ( - AuthProviderKind, - TokenProviderProtocol, +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + InvalidScanContinuation, + ScopeAccessLost, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, + parent_container_key, ) -from airweave.domains.storage import FileSkippedException +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceAuthError +from airweave.domains.sources.token_providers.protocol import SourceAuthProvider from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.auth import GitHubAuthConfig -from airweave.platform.configs.config import GitHubConfig -from airweave.platform.cursors import GitHubCursor +from airweave.platform.configs.config import GitHubConfig, GitHubRepositorySelection from airweave.platform.decorators import source -from airweave.platform.entities._base import BaseEntity, Breadcrumb -from airweave.platform.entities.github import ( - GitHubCodeFileEntity, - GitHubDirectoryEntity, - GitHubFileDeletionEntity, - GitHubPRCommentEntity, - GitHubPullRequestEntity, - GitHubRepositoryEntity, -) +from airweave.platform.entities._base import BaseEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( - retry_if_rate_limit_or_timeout, - wait_rate_limit_with_backoff, -) -from airweave.platform.utils.file_extensions import ( - get_language_for_extension, - is_text_file, +from airweave.platform.sources.github_http import GitHubReader, GitHubUnavailable +from airweave.platform.sources.github_models import ( + Branch, + BranchPayload, + Dates, + FileProgress, + GitCommit, + Identity, + Issue, + IssuePayload, + ListProgress, + PullRequest, + Repository, + RepositoryContext, + RootProgress, + Tree, + TreeEntry, + TreeFrame, ) from airweave.schemas.source_connection import AuthenticationMethod +FIELD_SET_VERSION = 1 +MAX_CODE_BYTES = 10 * 1024 * 1024 + @source( name="GitHub", @@ -55,701 +71,480 @@ auth_config_class=GitHubAuthConfig, config_class=GitHubConfig, labels=["Code"], - supports_continuous=True, - supports_temporal_relevance=False, - cursor_class=GitHubCursor, + supports_continuous=False, rate_limit_level=RateLimitLevel.ORG, ) class GitHubSource(BaseSource): - """GitHub source connector integrates with the GitHub REST API to extract and synchronize data. - - Connects to your GitHub repositories. - - It supports syncing repository metadata, directory structures, and code files with - configurable filtering options for branches and file types. - """ - - BASE_URL = "https://api.github.com" + """Full observed sweeps; no claim of an upstream transactional snapshot or event log.""" + + canonical_record_types = ( + "repository", + "branch", + "file", + "issue", + "comment", + "review", + "review_comment", + "pull_file", + ) + canonical_container_parents = { + "branch": "repository", + "file": "branch", + "issue": "repository", + "comment": "issue", + "review": "issue", + "review_comment": "issue", + "pull_file": "issue", + } + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Bind selections and field policy to existing cycle restart semantics.""" + return self._cycle_configuration @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: GitHubConfig, ) -> GitHubSource: - """Create a new source instance with authentication.""" + """Do not fetch or extract managed provider credentials.""" instance = cls(auth=auth, logger=logger, http_client=http_client) - if auth.provider_kind == AuthProviderKind.CREDENTIAL: - instance._personal_access_token = auth.credentials.personal_access_token - else: - instance._personal_access_token = await auth.get_token() - instance._repo_name = config.repo_name - instance._branch = config.branch or None - instance._sync_pull_requests = config.sync_pull_requests - instance._max_file_size = 10 * 1024 * 1024 - return instance - - def get_default_cursor_field(self) -> Optional[str]: - """Get the default cursor field for GitHub source.""" - return "last_repository_pushed_at" - - def validate_cursor_field(self, cursor_field: str) -> None: - """Validate if the given cursor field is valid for GitHub. - - Args: - cursor_field: The cursor field to validate - - Raises: - ValueError: If the cursor field is invalid - """ - valid_field = self.get_default_cursor_field() - if cursor_field != valid_field: - error_msg = ( - f"Invalid cursor field '{cursor_field}' for GitHub source. " - f"GitHub requires '{valid_field}' as the cursor field. " - f"GitHub tracks repository changes using push timestamps, not entity fields. " - f"Please use the default cursor field or omit it entirely." - ) - self.logger.warning(error_msg) - raise ValueError(error_msg) - - @retry( - stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, - reraise=True, - ) - async def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Make authenticated API request using Personal Access Token.""" - headers = { - "Authorization": f"token {self._personal_access_token}", - "Accept": "application/vnd.github.v3+json", - "X-GitHub-Api-Version": "2022-11-28", - } - response = await self.http_client.get(url, headers=headers, params=params) - - if response.status_code == 401 and self.auth.supports_refresh: - new_token = await self.auth.force_refresh() - headers["Authorization"] = f"token {new_token}" - response = await self.http_client.get(url, headers=headers, params=params) - - raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + instance.config = config + instance.reader = GitHubReader(http_client, auth) + instance.selections = {item.repository_id: item for item in config.repositories} + fingerprint = {"config": config.model_dump(mode="json"), "fields": FIELD_SET_VERSION} + instance._cycle_configuration = CycleConfiguration.from_source( + fingerprint=hashlib.sha256( + json.dumps(fingerprint, sort_keys=True).encode() + ).hexdigest(), + record_types=cls.canonical_record_types, + container_parents=cls.canonical_container_parents, ) - return response.json() - - async def _get_paginated_results( - self, url: str, params: Optional[Dict[str, Any]] = None - ) -> List[Dict[str, Any]]: - """Get all pages of results from a paginated GitHub API endpoint.""" - if params is None: - params = {} - - params["per_page"] = 100 - - all_results = [] - page = 1 - - while True: - params["page"] = page - headers = { - "Authorization": f"token {self._personal_access_token}", - "Accept": "application/vnd.github.v3+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - response = await self.http_client.get(url, headers=headers, params=params) - - if response.status_code == 401 and self.auth.supports_refresh: - new_token = await self.auth.force_refresh() - headers["Authorization"] = f"token {new_token}" - response = await self.http_client.get(url, headers=headers, params=params) + return instance - raise_for_status( - response, - source_short_name=self.short_name, + async def validate(self) -> None: + """Verify authentication independently from permission to every selected repository.""" + principal = Identity.model_validate(await self.reader.object("/user")) + if principal.id != self.config.expected_user_id: + raise SourceAuthError( + "GitHub authenticated user does not match the configured account", + source_short_name="github", + status_code=200, token_provider_kind=self.auth.provider_kind, ) - results = response.json() - if not results: - break - - all_results.extend(results) - - link_header = response.headers.get("Link", "") - if 'rel="next"' not in link_header: - break - - page += 1 - - return all_results - - def _detect_language_from_extension(self, file_path: str) -> str: - """Detect programming language from file extension.""" - ext = Path(file_path).suffix.lower() - return get_language_for_extension(ext) - - def _check_repository_updates( - self, repo_name: str, last_pushed_at: str, current_pushed_at: str - ) -> bool: - """Check if repository has been updated since last sync.""" - if last_pushed_at: - self.logger.debug( - f"Repository {repo_name} pushed_at: {last_pushed_at} -> {current_pushed_at}" - ) - has_updates = current_pushed_at > last_pushed_at - if has_updates: - self.logger.debug(f"Repository {repo_name} has new commits since last sync") - else: - self.logger.debug(f"Repository {repo_name} has no new commits since last sync") - return has_updates - else: - self.logger.debug(f"First sync for repository {repo_name}") - return True - - async def _get_repository_info( - self, repo_name: str, cursor: SyncCursor | None = None - ) -> GitHubRepositoryEntity: - """Get repository information with cursor support.""" - url = f"{self.BASE_URL}/repos/{repo_name}" - repo_data = await self._get(url) - - cursor_data = cursor.data if cursor else {} - - last_pushed_at = cursor_data.get("last_repository_pushed_at") - current_pushed_at = repo_data["pushed_at"] - - self._check_repository_updates(repo_name, last_pushed_at, current_pushed_at) - - if cursor: - cursor.update( - last_repository_pushed_at=current_pushed_at, - repo_name=repo_name, - branch=self._branch, - ) - - return GitHubRepositoryEntity.from_api(repo_data) - - async def _traverse_repository( - self, - repo_name: str, - branch: str, - cursor: SyncCursor | None = None, - files: FileService | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Traverse repository contents using DFS.""" - repo_entity = await self._get_repository_info(repo_name, cursor=cursor) - yield repo_entity - - owner, repo = repo_name.split("/") - - repo_breadcrumb = Breadcrumb( - entity_id=str(repo_entity.repo_id), - name=repo_entity.name, - entity_type=GitHubRepositoryEntity.__name__, + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Use one bounded, stable parent identity policy at every hierarchy depth.""" + if self.canonical_container_parents.get(record_type) != parent.identity.record_type: + raise ValueError("GitHub child scope does not belong to this parent") + return CompletedScope( + record_type=record_type, + container_id=parent_container_key(parent.identity), + parent=parent.identity, ) - processed_paths = set() - processed_files = set() - - async for entity in self._traverse_directory( - repo_name, - "", - [repo_breadcrumb], - owner, - repo, - branch, - processed_paths, - processed_files, - files=files, - ): - yield entity - - async def _traverse_repository_incremental( - self, - repo_name: str, - branch: str, - since_timestamp: str, - cursor: SyncCursor | None = None, - files: FileService | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Traverse repository contents incrementally using commits since last sync.""" - repo_entity = await self._get_repository_info(repo_name, cursor=cursor) - yield repo_entity - - owner, repo = repo_name.split("/") - - repo_breadcrumb = Breadcrumb( - entity_id=str(repo_entity.repo_id), - name=repo_entity.name, - entity_type=GitHubRepositoryEntity.__name__, + @staticmethod + def _record( + scope: CompletedScope, native_id: str, payload: dict[str, JsonValue] + ) -> CaptureRecord: + native = payload.get("issue", payload.get("native", payload)) + dates = Dates.model_validate(native) + body = native.get("body") if isinstance(native, dict) else None + linked_attachment = isinstance(body, str) and any( + host in body + for host in ("github.com/user-attachments/", "user-images.githubusercontent.com/") + ) + return CaptureRecord( + identity=RecordIdentity( + record_type=scope.record_type, native_id=native_id, container_id=scope.container_id + ), + parent=scope.parent, + payload=payload, + payload_schema_version=FIELD_SET_VERSION, + completeness="partial" if linked_attachment else "complete", + observed_at=datetime.now(timezone.utc), + source_created_at=dates.created_at, + source_updated_at=dates.updated_at, ) - commits = await self._get_commits_since(repo_name, since_timestamp, branch) - - if not commits: - self.logger.debug(f"No new commits found since {since_timestamp}") - return - - self.logger.debug(f"Found {len(commits)} new commits since {since_timestamp}") - - processed_files_set = set() - - for commit in commits: - commit_sha = commit["sha"] - commit_message = commit["commit"]["message"] - - self.logger.debug(f"Processing commit {commit_sha[:8]}: {commit_message}") - - changed_files = await self._get_commit_files(repo_name, commit_sha) - - for file_info in changed_files: - file_path = file_info["filename"] - - if file_path in processed_files_set: - continue - - processed_files_set.add(file_path) - - if file_info["status"] == "removed": - self.logger.debug(f"Processing deleted file: {file_path}") - deletion_entity = GitHubFileDeletionEntity( - breadcrumbs=[], - full_path=f"{repo_name}/{file_path}", - deletion_label=f"Deleted file {file_path}", - file_path=file_path, - repo_name=repo, - repo_owner=owner, - branch=branch, - deletion_status="removed", - ) - yield deletion_entity - continue - - try: - async for entity in self._process_changed_file( - repo_name, file_path, owner, repo, branch, repo_breadcrumb, files=files - ): - yield entity - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing changed file {file_path}: {e}") - - async def _get_commits_since( - self, repo_name: str, since_timestamp: str, branch: str - ) -> List[Dict[str, Any]]: - """Get commits since a specific timestamp.""" - url = f"{self.BASE_URL}/repos/{repo_name}/commits" - params = { - "since": since_timestamp, - "sha": branch, - "per_page": 100, - } - - commits = await self._get_paginated_results(url, params) - self.logger.debug(f"Retrieved {len(commits)} commits since {since_timestamp}") - return commits + @staticmethod + def _page( + records: list[CaptureRecord], + progress: RootProgress | ListProgress | FileProgress, + *, + final: bool, + ) -> CapturePage: + return CapturePage( + records=tuple(records), + final=final, + continuation=ScanContinuation(value=progress.model_dump(mode="json")), + ) - async def _get_commit_files(self, repo_name: str, commit_sha: str) -> List[Dict[str, Any]]: - """Get files changed in a specific commit.""" - url = f"{self.BASE_URL}/repos/{repo_name}/commits/{commit_sha}" + @staticmethod + def _context(repository: Repository) -> RepositoryContext: + return RepositoryContext( + repository_id=repository.id, + owner_id=repository.owner.id, + full_name=repository.full_name, + ) + async def _repository( + self, selection: GitHubRepositorySelection + ) -> dict[str, JsonValue] | None: try: - commit_data = await self._get(url) - files_list = commit_data.get("files", []) - self.logger.debug(f"Commit {commit_sha[:8]} changed {len(files_list)} files") - return files_list - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error getting files for commit {commit_sha}: {e}") - return [] - - async def _process_changed_file( + payload = await self.reader.object("/repos/" + selection.full_name, redirects=True) + except GitHubUnavailable: + return None + repository = Repository.model_validate(payload) + # Numeric owner selection is an authorization constraint, not repository identity. + if repository.id != selection.repository_id or repository.owner.id != selection.owner_id: + return None + return payload + + async def capture_page( self, - repo_name: str, - file_path: str, - owner: str, - repo: str, - branch: str, - repo_breadcrumb: Breadcrumb, + scope: CompletedScope, + continuation: ScanContinuation, *, - files: FileService | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Process a single changed file.""" - path_parts = file_path.split("/") - breadcrumbs = [repo_breadcrumb] - - current_path = "" - for _i, part in enumerate(path_parts[:-1]): - current_path = f"{current_path}/{part}" if current_path else part - dir_breadcrumb = Breadcrumb( - entity_id=f"{repo_name}/{current_path}", - name=part, - entity_type=GitHubDirectoryEntity.__name__, - ) - breadcrumbs.append(dir_breadcrumb) - + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Provider I/O happens outside SQL; the driver commits returned progress atomically.""" + if scope.record_type == "repository": + if parent is not None or scope.container_id is not None: + raise ValueError("GitHub repository inventory must be a root scope") + return await self._repositories(scope, continuation) + if parent is None or scope != self.child_scope(parent, scope.record_type): + raise ValueError("GitHub page requires its exact captured parent") + file_progress = None + if scope.record_type == "file": + file_progress = FileProgress.model_validate(continuation.value) + branch = Branch.model_validate(BranchPayload.model_validate(parent.payload).branch) + if file_progress.initialized and ( + file_progress.ref != branch.name or file_progress.commit_sha != branch.commit.sha + ): + raise InvalidScanContinuation( + "GitHub selected branch changed since the pinned scan" + ) try: - file_url = f"{self.BASE_URL}/repos/{repo_name}/contents/{file_path}?ref={branch}" - file_data = await self._get(file_url) - - file_size = file_data.get("size", 0) - if file_size > self._max_file_size: - self.logger.debug(f"Skipping large file: {file_path} ({file_size} bytes)") - return - - content_url = file_data["download_url"] - content_response = await self.http_client.get(content_url) - content_response.raise_for_status() - content_text = content_response.text - - if not self._is_text_content(content_text): - self.logger.debug(f"Skipping binary file: {file_path}") - return - - language = self._detect_language_from_extension(file_path) - line_count = content_text.count("\n") + 1 - mime_type = mimetypes.guess_type(file_path)[0] or "text/plain" - file_type = mime_type.split("/")[0] if "/" in mime_type else "file" - - file_entity = GitHubCodeFileEntity( - breadcrumbs=breadcrumbs, - full_path=f"{repo_name}/{file_path}", - name=path_parts[-1], - branch=branch, - url=file_data.get("download_url") or file_data.get("html_url", ""), - size=file_size, - file_type=file_type, - mime_type=mime_type, - local_path=None, - repo_name=repo, - path_in_repo=file_path, - repo_owner=owner, - language=language, - commit_id=file_data["sha"], - html_url=file_data.get("html_url"), - sha=file_data["sha"], - line_count=line_count, - is_binary=False, - ) - - if files: - await files.save_bytes( - entity=file_entity, - content=content_text.encode("utf-8"), - filename_with_extension=file_path, - logger=self.logger, + await self._verify_parent_repository(parent) + if scope.record_type == "branch": + return await self._branches(scope, parent) + if scope.record_type == "file": + assert file_progress is not None + return await self._files(scope, file_progress, parent, files) + if scope.record_type == "issue": + return await self._issues(scope, continuation, parent) + return await self._conversation(scope, continuation, parent) + except GitHubUnavailable as error: + raise ScopeAccessLost( + "GitHub parent is no longer readable", removal_reason="scope_removed" + ) from error + + async def _verify_parent_repository(self, parent: SourceRecord) -> None: + """A captured path is routing context, never continuing authorization after rename/reuse.""" + if parent.identity.record_type == "repository": + context = self._context(Repository.model_validate(parent.payload)) + elif parent.identity.record_type == "branch": + context = BranchPayload.model_validate(parent.payload).repository + else: + context = IssuePayload.model_validate(parent.payload).repository + current = Repository.model_validate(await self.reader.object("/repos/" + context.full_name)) + if current.id != context.repository_id or current.owner.id != context.owner_id: + raise GitHubUnavailable("GitHub repository no longer matches the authorized selection") + if parent.identity.record_type == "issue": + issue = Issue.model_validate(IssuePayload.model_validate(parent.payload).issue) + current_issue = Issue.model_validate( + await self.reader.object( + f"/repos/{context.full_name}/issues/{issue.number}", redirects=True ) - - if not file_entity.local_path: - raise ValueError(f"Save failed - no local path set for {file_entity.name}") - - yield file_entity - - except FileSkippedException as e: - self.logger.debug(f"Skipping file: {e.reason}") - - except SourceAuthError: - raise - - except Exception as e: - self.logger.warning(f"Error processing changed file {file_path}: {e}") - - def _is_text_content(self, content: str) -> bool: - """Check if content appears to be text.""" - if "\x00" in content: - return False - printable_ratio = sum(1 for c in content[:1000] if c.isprintable() or c.isspace()) / min( - len(content), 1000 + ) + if current_issue.id != issue.id or current_issue.repository_url != ( + f"https://api.github.com/repos/{context.full_name}" + ): + raise GitHubUnavailable("GitHub issue no longer matches the captured parent") + + async def _repositories( + self, scope: CompletedScope, continuation: ScanContinuation + ) -> CapturePage: + progress = RootProgress.model_validate(continuation.value) + if progress.repository_index > len(self.config.repositories): + raise InvalidScanContinuation("GitHub repository selection changed") + records = [] + if progress.repository_index < len(self.config.repositories): + selection = self.config.repositories[progress.repository_index] + payload = await self._repository(selection) + if payload is not None: + records.append(self._record(scope, str(selection.repository_id), payload)) + progress.repository_index += 1 + return self._page( + records, progress, final=progress.repository_index == len(self.config.repositories) ) - return printable_ratio > 0.7 - - async def _traverse_directory( # noqa: C901 - self, - repo_name: str, - path: str, - breadcrumbs: List[Breadcrumb], - owner: str, - repo: str, - branch: str, - processed_paths: set, - processed_files: set, - *, - files: FileService | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Recursively traverse a directory using DFS.""" - if path in processed_paths: - return - - processed_paths.add(path) - - url = f"{self.BASE_URL}/repos/{repo_name}/contents/{path}" - params = {"ref": branch} + async def _branches(self, scope: CompletedScope, parent: SourceRecord) -> CapturePage: + repository = Repository.model_validate(parent.payload) + selection = self.selections[repository.id] + if not self.config.include_code: + return self._page([], RootProgress(), final=True) + ref = selection.ref or repository.default_branch try: - contents = await self._get(url, params) + payload = await self.reader.object( + f"/repos/{repository.full_name}/branches/{quote(ref, safe='')}" + ) + except GitHubUnavailable: + if await self._repository(selection) is None: + raise + # A missing branch is not proof that repository issues also became private. + return self._page([], RootProgress(), final=True) + branch = Branch.model_validate(payload) + if branch.name != ref: + raise ValueError("GitHub returned a different selected branch") + body = BranchPayload(branch=payload, repository=self._context(repository)) + return self._page( + [self._record(scope, branch.name, body.model_dump(mode="json"))], + RootProgress(), + final=True, + ) - if isinstance(contents, List): - items = contents + async def _issues( + self, scope: CompletedScope, continuation: ScanContinuation, parent: SourceRecord + ) -> CapturePage: + progress = ListProgress.model_validate(continuation.value) + if not self.config.include_conversations: + return self._page([], progress, final=True) + repository = Repository.model_validate(parent.payload) + path = f"/repos/{repository.full_name}" + payloads, final = await self.reader.page( + path + "/issues", progress.page, extra="state=all&sort=created&direction=asc" + ) + records = [] + for payload in payloads: + issue = Issue.model_validate(payload) + if issue.repository_url != "https://api.github.com" + path: + raise ValueError("GitHub issue belongs to a different repository") + detail = None + if issue.pull_request is not None: + detail = await self.reader.object(f"{path}/pulls/{issue.number}") + if PullRequest.model_validate(detail).number != issue.number: + raise ValueError("GitHub returned a different pull request") + body = IssuePayload( + issue=payload, repository=self._context(repository), pull_request_detail=detail + ) + records.append(self._record(scope, str(issue.id), body.model_dump(mode="json"))) + progress.page += 1 + progress.count += len(records) + return self._page(records, progress, final=final) + + async def _conversation( + self, scope: CompletedScope, continuation: ScanContinuation, parent: SourceRecord + ) -> CapturePage: + progress = ListProgress.model_validate(continuation.value) + body = IssuePayload.model_validate(parent.payload) + issue = Issue.model_validate(body.issue) + base = f"/repos/{body.repository.full_name}" + if scope.record_type != "comment" and issue.pull_request is None: + return self._page([], progress, final=True) + suffix = { + "comment": f"issues/{issue.number}/comments", + "review": f"pulls/{issue.number}/reviews", + "review_comment": f"pulls/{issue.number}/comments", + "pull_file": f"pulls/{issue.number}/files", + }[scope.record_type] + before = None + if scope.record_type == "pull_file": + before = PullRequest.model_validate( + await self.reader.object(f"{base}/pulls/{issue.number}") + ) + if progress.head_sha is not None and ( + progress.head_sha != before.head.sha or progress.base_sha != before.base.sha + ): + raise InvalidScanContinuation("GitHub pull request changed while listing files") + progress.head_sha, progress.base_sha = before.head.sha, before.base.sha + payloads, final = await self.reader.page(f"{base}/{suffix}", progress.page) + if before is not None: + await self._verify_pull_files( + base, issue.number, before, progress.count + len(payloads), final + ) + records = [] + for payload in payloads: + if scope.record_type == "pull_file": + native_id = payload.get("filename") + if not isinstance(native_id, str) or not native_id: + raise ValueError("GitHub pull request file has no native path") else: - items = [contents] - - for item in items: - item_path = item["path"] - item_type = item["type"] - - if item_type == "dir": - dir_entity = GitHubDirectoryEntity( - breadcrumbs=breadcrumbs.copy(), - full_path=f"{repo_name}/{item_path}", - name=Path(item_path).name, - path=item_path, - repo_name=repo, - repo_owner=owner, - branch=branch, - ) - - dir_breadcrumb = Breadcrumb( - entity_id=dir_entity.full_path, - name=dir_entity.name, - entity_type=GitHubDirectoryEntity.__name__, - ) - - yield dir_entity - - dir_breadcrumbs = breadcrumbs.copy() + [dir_breadcrumb] - - async for child_entity in self._traverse_directory( - repo_name, - item_path, - dir_breadcrumbs, - owner, - repo, - branch, - processed_paths, - processed_files, - files=files, - ): - yield child_entity - - elif item_type == "file": - if item_path in processed_files: - continue - - processed_files.add(item_path) - - async for file_entity in self._process_file( - repo_name, item_path, item, breadcrumbs, owner, repo, branch, files=files - ): - yield file_entity - - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error traversing path {path}: {str(e)}") - - async def _process_file( # noqa: C901 + native_id = str(Identity.model_validate(payload).id) + captured = { + "native": payload, + "repository": body.repository.model_dump(mode="json"), + "issue_number": issue.number, + } + if before is not None: + captured.update({"head_sha": before.head.sha, "base_sha": before.base.sha}) + record = self._record(scope, native_id, captured) + # PR file API is metadata/patch coverage; original bytes live in selected branch files. + if scope.record_type == "pull_file": + record = record.model_copy(update={"completeness": "metadata_only"}) + records.append(record) + progress.page += 1 + progress.count += len(records) + return self._page(records, progress, final=final) + + async def _verify_pull_files( + self, base: str, number: int, before: PullRequest, count: int, final: bool + ) -> None: + after = PullRequest.model_validate(await self.reader.object(f"{base}/pulls/{number}")) + if (after.head.sha, after.base.sha) != (before.head.sha, before.base.sha): + raise InvalidScanContinuation("GitHub pull request changed while listing files") + if count >= 3000 and (not final or before.changed_files > count): + raise ValueError("GitHub pull request file listing exceeds its complete API window") + if final and count != before.changed_files: + raise ValueError("GitHub pull request file count was not completely enumerated") + + async def _files( self, - repo_name: str, - item_path: str, - item: Dict[str, Any], - breadcrumbs: List[Breadcrumb], - owner: str, - repo: str, - branch: str, - *, - files: FileService | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Process a file item and create file entities.""" + scope: CompletedScope, + progress: FileProgress, + parent: SourceRecord, + files: FileService, + ) -> CapturePage: + body = BranchPayload.model_validate(parent.payload) + branch = Branch.model_validate(body.branch) + base = f"/repos/{body.repository.full_name}/git" try: - file_url = f"{self.BASE_URL}/repos/{repo_name}/contents/{item_path}" - file_data = await self._get(file_url, {"ref": branch}) - file_size = file_data.get("size", 0) - - content_sample = None - content_text = None - if file_data.get("encoding") == "base64" and file_data.get("content"): - try: - content_sample = base64.b64decode(file_data["content"]) - content_text = content_sample.decode("utf-8", errors="replace") - except Exception: - pass - - if is_text_file(item_path, file_size, content_sample): - language = self._detect_language_from_extension(item_path) - file_name = Path(item_path).name - - line_count = 0 - if content_text: - try: - line_count = content_text.count("\n") + 1 - except Exception as e: - self.logger.warning(f"Error counting lines for {item_path}: {str(e)}") - - mime_type = mimetypes.guess_type(item_path)[0] or "text/plain" - file_type = mime_type.split("/")[0] if "/" in mime_type else "file" - - file_entity = GitHubCodeFileEntity( - breadcrumbs=breadcrumbs.copy(), - full_path=f"{repo_name}/{item_path}", - name=file_name, - branch=branch, - url=file_data.get("download_url") or file_data["html_url"], - size=file_size, - file_type=file_type, - mime_type=mime_type, - local_path=None, - repo_name=repo, - path_in_repo=item_path, - repo_owner=owner, - language=language, - commit_id=file_data["sha"], - html_url=file_data.get("html_url"), - sha=file_data["sha"], - line_count=line_count, - is_binary=False, - ) - - if files and content_text: - await files.save_bytes( - entity=file_entity, - content=content_text.encode("utf-8"), - filename_with_extension=item_path, - logger=self.logger, + progress = await self._pin_code(progress, branch, base) + records = [] + stack = list(progress.stack) + # One tree response per page bounds work for enormous empty directory trees. + if stack: + frame = stack[-1] + tree = Tree.model_validate(await self.reader.object(f"{base}/trees/{frame.sha}")) + if tree.sha != frame.sha or tree.truncated or frame.offset > len(tree.tree): + raise ValueError("GitHub tree cannot be completely and consistently enumerated") + while frame.offset < len(tree.tree) and len(records) < 1: + raw = tree.tree[frame.offset] + entry = TreeEntry.model_validate(raw) + if entry.path in {".", ".."} or "/" in entry.path or "\x00" in entry.path: + raise ValueError("GitHub tree entry is not a single native path component") + frame.offset += 1 + if entry.type == "tree": + stack.append(TreeFrame(sha=entry.sha, component=entry.path)) + break + path = "/".join( + [item.component for item in stack if item.component] + [entry.path] ) - - if not file_entity.local_path: - raise ValueError(f"Save failed - no local path set for {file_entity.name}") - - yield file_entity - except FileSkippedException as e: - self.logger.debug(f"Skipping file: {e.reason}") - - except SourceAuthError: - raise - - except Exception as e: - self.logger.warning(f"Error processing file {item_path}: {str(e)}") - - def _get_cursor_timestamp(self, cursor: SyncCursor | None) -> str: - """Get last repository pushed timestamp from cursor.""" - cursor_data = cursor.data if cursor else {} - last_pushed_at = cursor_data.get("last_repository_pushed_at") - - if last_pushed_at: - self.logger.debug(f"Incremental sync from cursor: {last_pushed_at}") - else: - self.logger.debug("Full sync (no cursor)") - - return last_pushed_at - - async def _verify_branch(self, branch: str) -> None: - """Verify that the specified branch exists in the repository.""" - if self._branch: - branches_url = f"{self.BASE_URL}/repos/{self._repo_name}/branches" - branches_data = await self._get_paginated_results(branches_url) - branch_names = [b["name"] for b in branches_data] - - if branch not in branch_names: - available_branches = ", ".join(branch_names) - raise ValueError( - f"Branch '{branch}' not found in repository '{self._repo_name}'. " - f"Available branches: {available_branches}" - ) - - async def _fetch_merged_pull_requests( - self, - repo_name: str, - repo_breadcrumb: Breadcrumb, - since: Optional[str] = None, - cursor: SyncCursor | None = None, - ) -> AsyncGenerator[BaseEntity, None]: - """Fetch merged pull requests and their review comments.""" - owner, repo = repo_name.split("/") - url = f"{self.BASE_URL}/repos/{repo_name}/pulls" - params = {"state": "closed", "sort": "updated", "direction": "desc"} - - all_prs = await self._get_paginated_results(url, params) - - latest_updated_at = since or "" - - for pr_data in all_prs: - if not pr_data.get("merged_at"): - continue - - pr_updated = pr_data["updated_at"] - - if since and pr_updated <= since: - continue - - if pr_updated > latest_updated_at: - latest_updated_at = pr_updated - - pr_number = pr_data["number"] - - pr_breadcrumb = Breadcrumb( - entity_id=f"{repo_name}#{pr_number}", - name=f"PR #{pr_number}", - entity_type=GitHubPullRequestEntity.__name__, + payload = { + "entry": raw, + "path": path, + "ref": progress.ref, + "commit_sha": progress.commit_sha, + "tree_sha": progress.tree_sha, + "repository": body.repository.model_dump(mode="json"), + } + record = self._record(scope, path, payload) + record = await self._retain_blob(record, entry, base, path, files) + records.append(record) + if stack[-1] is frame and frame.offset == len(tree.tree): + stack.pop() + progress.stack = tuple(stack) + return self._page(records, progress, final=not stack) + except GitHubUnavailable as error: + # An unreadable branch withdraws this code scope. A still-readable branch + # with a missing immutable snapshot requires restart, never absence cleanup. + await self.reader.object( + f"/repos/{body.repository.full_name}/branches/{quote(branch.name, safe='')}" ) + raise InvalidScanContinuation("GitHub pinned code snapshot is unavailable") from error - files_data = await self._get_paginated_results( - f"{self.BASE_URL}/repos/{repo_name}/pulls/{pr_number}/files", - {}, - ) - changed_paths = [f["filename"] for f in files_data if f.get("filename")] + async def _pin_code(self, progress: FileProgress, branch: Branch, base: str) -> FileProgress: + if progress.initialized: + return progress + commit = GitCommit.model_validate( + await self.reader.object(f"{base}/commits/{branch.commit.sha}") + ) + if commit.sha != branch.commit.sha: + raise ValueError("GitHub returned a different pinned commit") + return FileProgress( + ref=branch.name, + commit_sha=commit.sha, + tree_sha=commit.tree.sha, + stack=(TreeFrame(sha=commit.tree.sha, component=""),), + initialized=True, + ) - pr_entity = GitHubPullRequestEntity.from_api( - pr_data, - repo_name=repo, - repo_owner=owner, - changed_files_list=changed_paths or None, - breadcrumbs=[repo_breadcrumb], - ) - yield pr_entity + async def _retain_blob( + self, record: CaptureRecord, entry: TreeEntry, base: str, path: str, files: FileService + ) -> CaptureRecord: + if entry.type == "commit": + return record.model_copy(update={"completeness": "metadata_only"}) + if entry.size is None or entry.size > MAX_CODE_BYTES: + return record.model_copy(update={"completeness": "partial"}) + content = await self.reader.blob( + f"{base}/blobs/{entry.sha}", max_bytes=min(entry.size, MAX_CODE_BYTES) + ) + if len(content) != entry.size or len(content) > MAX_CODE_BYTES: + raise ValueError("GitHub blob size differs from its immutable tree entry") + digest = hashlib.sha1(f"blob {len(content)}\0".encode() + content).hexdigest() + if digest != entry.sha: + raise ValueError("GitHub blob does not match its immutable object identity") + blob = await files.store_canonical_blob(content, media_type=mimetypes.guess_type(path)[0]) + lfs_pointer = content.startswith(b"version https://git-lfs.github.com/spec/v1\n") + return record.model_copy( + update={"blobs": (blob,), "completeness": "partial" if lfs_pointer else "complete"} + ) - async for comment_entity in self._fetch_pr_review_comments( - repo_name, pr_number, owner, repo, [repo_breadcrumb, pr_breadcrumb] + async def confirm_absent(self, record: SourceRecord) -> None: + """Confirm omitted inventory owners before descendants lose visibility.""" + kind = record.identity.record_type + if kind == "repository": + selection = self.selections.get(int(record.identity.native_id)) + if selection is None or await self._repository(selection) is None: + return + elif kind == "branch": + if await self._branch_absent(record): + return + elif kind == "issue": + if not self.config.include_conversations: + return + body = IssuePayload.model_validate(record.payload) + issue = Issue.model_validate(body.issue) + try: + current = Issue.model_validate( + await self.reader.object( + f"/repos/{body.repository.full_name}/issues/{issue.number}", redirects=True + ) + ) + except GitHubUnavailable: + return + if current.id != issue.id or current.repository_url != ( + f"https://api.github.com/repos/{body.repository.full_name}" ): - yield comment_entity - - if cursor and latest_updated_at and latest_updated_at != since: - cursor.update(last_pr_updated_at=latest_updated_at) + return + else: + raise ValueError("GitHub absence confirmation is only valid for inventory owners") + raise ValueError("GitHub omitted an inventory record that remains readable in selection") - async def _fetch_pr_review_comments( - self, - repo_name: str, - pr_number: int, - owner: str, - repo: str, - breadcrumbs: List[Breadcrumb], - ) -> AsyncGenerator[BaseEntity, None]: - """Fetch review comments (inline code comments) for a single PR.""" - url = f"{self.BASE_URL}/repos/{repo_name}/pulls/{pr_number}/comments" + async def _branch_absent(self, record: SourceRecord) -> bool: + body = BranchPayload.model_validate(record.payload) + selection = self.selections.get(body.repository.repository_id) + if selection is None or not self.config.include_code: + return True + repository = await self._repository(selection) + if repository is None: + return True + ref = selection.ref or Repository.model_validate(repository).default_branch + if ref != record.identity.native_id: + return True try: - comments = await self._get_paginated_results(url) - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error fetching review comments for PR #{pr_number}: {e}") - return - - for comment in comments: - yield GitHubPRCommentEntity.from_api( - comment, - repo_name=repo, - repo_owner=owner, - pr_number=pr_number, - breadcrumbs=breadcrumbs.copy(), + await self.reader.object( + f"/repos/{body.repository.full_name}/branches/{quote(ref, safe='')}" ) + except GitHubUnavailable: + return True + return False async def generate_entities( self, @@ -758,79 +553,6 @@ async def generate_entities( files: FileService | None = None, node_selections: list[NodeSelectionData] | None = None, ) -> AsyncGenerator[BaseEntity, None]: - """Generate entities from GitHub repository with incremental support. - - Yields: - Repository, directory, file, pull request, and PR comment entities - """ - if not self._repo_name: - raise ValueError("Repository name must be specified") - - last_pushed_at = self._get_cursor_timestamp(cursor) - - repo_url = f"{self.BASE_URL}/repos/{self._repo_name}" - repo_data = await self._get(repo_url) - current_pushed_at = repo_data["pushed_at"] - - branch = self._branch if self._branch else repo_data["default_branch"] - - await self._verify_branch(branch) - - should_sync = True - if last_pushed_at and current_pushed_at <= last_pushed_at: - self.logger.debug( - f"Repository {self._repo_name} has no new commits since last sync, " - "skipping file traversal" - ) - should_sync = False - - if should_sync: - self.logger.debug(f"Using branch: {branch} for repo {self._repo_name}") - - if last_pushed_at: - self.logger.debug(f"Performing INCREMENTAL sync - changes since {last_pushed_at}") - async for entity in self._traverse_repository_incremental( - self._repo_name, branch, last_pushed_at, cursor=cursor, files=files - ): - yield entity - else: - self.logger.debug("Performing FULL sync - no previous cursor data") - async for entity in self._traverse_repository( - self._repo_name, branch, cursor=cursor, files=files - ): - yield entity - else: - repo_entity = await self._get_repository_info(self._repo_name, cursor=cursor) - yield repo_entity - - if self._sync_pull_requests: - cursor_data = cursor.data if cursor else {} - last_pr_updated = cursor_data.get("last_pr_updated_at") or None - - repo_breadcrumb = Breadcrumb( - entity_id=str(repo_data["id"]), - name=repo_data["name"], - entity_type=GitHubRepositoryEntity.__name__, - ) - - if last_pr_updated: - self.logger.debug(f"Incremental PR sync - changes since {last_pr_updated}") - else: - self.logger.debug("Full PR sync - no previous cursor data") - - async for entity in self._fetch_merged_pull_requests( - self._repo_name, repo_breadcrumb, since=last_pr_updated, cursor=cursor - ): - yield entity - - async def validate(self) -> None: - """Verify GitHub PAT and optional repo/branch access via the same path as sync. - - Uses :meth:`_get` (Airweave client + ``raise_for_status``) so failures surface as - the same domain errors as the rest of the connector. Relies on :meth:`create` for - ``_personal_access_token``, ``_repo_name``, and ``_branch``. - """ - if self._repo_name: - await self._get(f"{self.BASE_URL}/repos/{self._repo_name}") - if self._branch: - await self._get(f"{self.BASE_URL}/repos/{self._repo_name}/branches/{self._branch}") + """Replace the legacy lossy traversal; the registry chooses canonical capture.""" + raise NotImplementedError("GitHub requires canonical page capture") + yield # pragma: no cover diff --git a/backend/airweave/platform/sources/github_http.py b/backend/airweave/platform/sources/github_http.py new file mode 100644 index 0000000000..90df4e86e7 --- /dev/null +++ b/backend/airweave/platform/sources/github_http.py @@ -0,0 +1,217 @@ +"""GitHub read transport: bounded trusted redirects and strict native pagination.""" + +from __future__ import annotations + +import time +from contextlib import aclosing +from functools import partial +from urllib.parse import parse_qs, urlsplit + +import httpx +from pydantic import JsonValue, TypeAdapter +from tenacity import retry, retry_if_exception_type, stop_after_attempt + +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError +from airweave.domains.sources.exceptions import ( + SourceAuthError, + SourceError, + SourceRateLimitError, + SourceServerError, +) +from airweave.domains.sources.token_providers.credential import DirectCredentialProvider +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) +from airweave.platform.configs.auth import GitHubAuthConfig +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.retry_helpers import ( + retry_if_rate_limit_or_timeout, + wait_rate_limit_with_backoff, +) + +BASE_URL = "https://api.github.com" +OBJECT = TypeAdapter(dict[str, JsonValue]) +PAGE = TypeAdapter(list[dict[str, JsonValue]]) + + +class GitHubUnavailable(Exception): + """A non-rate-limited 403/404; never proof of upstream deletion.""" + + +def trusted_url(url: str) -> bool: + """Never send managed credentials to provider-supplied arbitrary locations.""" + parsed = urlsplit(url) + return ( + parsed.scheme == "https" + and parsed.netloc == "api.github.com" + and not parsed.fragment + and not parsed.username + and not parsed.password + ) + + +class GitHubReader: + """Existing shared client and auth own network access; this adds GitHub semantics only.""" + + def __init__(self, client: AirweaveHttpClient, auth: SourceAuthProvider): + """Reuse the source-scoped native transport and credential owner.""" + self.client = client + self.auth = auth + + @retry( + stop=stop_after_attempt(5), + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), + reraise=True, + ) + async def get(self, path: str, *, raw: bool = False, redirects: bool = False) -> httpx.Response: + """Read a native endpoint without exposing provider error bodies in logs.""" + url = BASE_URL + path + refreshed = False + for _ in range(4): + if not trusted_url(url): + raise SourceError( + "GitHub returned an untrusted redirect", source_short_name="github" + ) + headers = await self._headers(refresh=refreshed, raw=raw) + response = await self.client.get(url, headers=headers, follow_redirects=False) + if response.status_code == 401 and self.auth.supports_refresh and not refreshed: + refreshed = True + continue + if response.status_code in {301, 302, 307, 308}: + if not redirects: + raise SourceError( + "GitHub captured route changed; refresh its parent inventory", + source_short_name="github", + ) + url = response.headers.get("Location", "") + continue + self._status(response) + return response + raise SourceError( + "GitHub redirect or authentication retry did not resolve", source_short_name="github" + ) + + async def _headers(self, *, refresh: bool = False, raw: bool = False) -> dict[str, str]: + if isinstance(self.auth, DirectCredentialProvider): + credentials = GitHubAuthConfig.model_validate(self.auth.credentials) + headers = {"Authorization": f"Bearer {credentials.personal_access_token}"} + else: + headers = await authorization_headers(self.auth, refresh=refresh) + headers.update( + { + "Accept": "application/vnd.github.raw+json" + if raw + else "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + return headers + + @retry( + stop=stop_after_attempt(5), + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), + reraise=True, + ) + async def blob(self, path: str, *, max_bytes: int) -> bytes: + """Bound bytes while the existing HTTP transport streams the native blob.""" + for refresh in (False, True): + headers = await self._headers(refresh=refresh, raw=True) + headers["Accept-Encoding"] = "identity" + async with self.client.stream( + "GET", BASE_URL + path, headers=headers, follow_redirects=False + ) as response: + if response.status_code == 401 and not refresh and self.auth.supports_refresh: + continue + if response.status_code != 200: + error_body = bytearray() + async with aclosing(response.aiter_raw()) as chunks: + async for chunk in chunks: + error_body.extend(chunk[: 65536 - len(error_body)]) + if len(error_body) >= 65536: + break + # Status handling sees only a bounded error envelope, never logs its body. + buffered = httpx.Response( + response.status_code, headers=response.headers, content=bytes(error_body) + ) + self._status(buffered) + if response.headers.get("content-encoding", "identity") != "identity": + raise ValueError("GitHub ignored the requested identity blob encoding") + length = response.headers.get("content-length") + if length is not None and int(length) > max_bytes: + raise ValueError("GitHub blob exceeds its retained byte bound") + content = bytearray() + async with aclosing(response.aiter_raw()) as chunks: + async for chunk in chunks: + if len(content) + len(chunk) > max_bytes: + raise ValueError("GitHub blob exceeds its retained byte bound") + content.extend(chunk) + return bytes(content) + raise SourceError( + "GitHub authorization refresh did not resolve", source_short_name="github" + ) + + def _status(self, response: httpx.Response) -> None: + status = response.status_code + if status in {403, 429}: + message = "" + if "json" in response.headers.get("content-type", ""): + value = OBJECT.validate_python(response.json()).get("message") + message = value.lower() if isinstance(value, str) else "" + if ( + status == 429 + or "retry-after" in response.headers + or response.headers.get("x-ratelimit-remaining") == "0" + or "rate limit" in message + ): + try: + delay = float(response.headers.get("retry-after", "0")) + if delay <= 0: + delay = float(response.headers.get("x-ratelimit-reset", "0")) - time.time() + except ValueError: + delay = 60 + raise SourceRateLimitError(source_short_name="github", retry_after=max(60, delay)) + if status == 401: + raise SourceAuthError( + "GitHub authorization is unavailable", + source_short_name="github", + status_code=401, + token_provider_kind=self.auth.provider_kind, + ) + if status in {403, 404}: + raise GitHubUnavailable("GitHub resource is outside the currently readable scope") + if status >= 500: + raise SourceServerError( + "GitHub is temporarily unavailable", source_short_name="github", status_code=status + ) + if status != 200: + raise SourceError(f"GitHub read failed with HTTP {status}", source_short_name="github") + + async def object(self, path: str, *, redirects: bool = False) -> dict[str, JsonValue]: + """Validate only the JSON boundary, retaining every provider field.""" + return OBJECT.validate_python((await self.get(path, redirects=redirects)).json()) + + async def page( + self, path: str, page: int, *, extra: str = "" + ) -> tuple[list[dict[str, JsonValue]], bool]: + """Follow only the exact next page of this fixed endpoint/query.""" + query = f"per_page=100&page={page}" + ("&" + extra if extra else "") + response = await self.get(path + "?" + query) + records = PAGE.validate_python(response.json()) + if len(records) > 100: + raise ValueError("GitHub exceeded its requested page size") + following = response.links.get("next", {}).get("url") + if following is None: + return records, True + parsed = urlsplit(following) + expected = parse_qs(query) + expected["page"] = [str(page + 1)] + # Redirected repository names are accepted only by root identity resolution; + # mutable child routes cannot quietly switch their captured parent context. + if not trusted_url(following) or parsed.path != path or parse_qs(parsed.query) != expected: + raise ValueError("GitHub returned a non-advancing or out-of-scope page link") + if not records: + raise ValueError("GitHub returned an empty page with a continuation") + return records, False diff --git a/backend/airweave/platform/sources/github_models.py b/backend/airweave/platform/sources/github_models.py new file mode 100644 index 0000000000..192e1a6807 --- /dev/null +++ b/backend/airweave/platform/sources/github_models.py @@ -0,0 +1,187 @@ +"""Native GitHub validation and bounded provider progress, not a second capture state owner.""" + +from typing import Annotated, Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + StrictBool, + model_validator, +) + +Sha = Annotated[str, Field(pattern=r"^[a-f0-9]{40}$")] +NativeId = Annotated[int, Field(gt=0, strict=True)] + + +class NativeModel(BaseModel): + """Validate routing fields while retaining untouched responses separately.""" + + model_config = ConfigDict(extra="ignore") + + +class Identity(NativeModel): + """Native numeric object identity.""" + + id: NativeId + + +class Repository(Identity): + """Selected repository routing and authorization fields.""" + + full_name: str = Field(pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$") + owner: Identity + default_branch: str + + +class Issue(Identity): + """Native issue routing; pull requests also appear in this endpoint.""" + + number: int = Field(gt=0, strict=True) + repository_url: str + pull_request: dict[str, JsonValue] | None = None + + +class CommitSide(NativeModel): + """Native immutable Git commit identity.""" + + sha: Sha + + +class PullRequest(Identity): + """Pinned diff comparison and completeness count.""" + + number: int = Field(gt=0, strict=True) + head: CommitSide + base: CommitSide + changed_files: int = Field(ge=0) + + +class TreeEntry(NativeModel): + """Native nonrecursive Git tree entry.""" + + path: str = Field(min_length=1, max_length=4096) + mode: Literal["100644", "100755", "040000", "160000", "120000"] + type: Literal["blob", "tree", "commit"] + sha: Sha + size: int | None = Field(default=None, ge=0) + + @model_validator(mode="after") + def coherent_type(self) -> "TreeEntry": + """Git mode determines whether this is a blob, subtree, or submodule reference.""" + expected = ( + "tree" if self.mode == "040000" else "commit" if self.mode == "160000" else "blob" + ) + if self.type != expected: + raise ValueError("GitHub tree entry type does not match its native mode") + return self + + +class Tree(NativeModel): + """Native Git tree response with explicit truncation signal.""" + + sha: Sha + tree: list[dict[str, JsonValue]] + truncated: StrictBool + + +class GitCommit(NativeModel): + """Native commit and its immutable root tree.""" + + sha: Sha + tree: CommitSide + + +class Branch(NativeModel): + """Native selected branch and current commit.""" + + name: str = Field(min_length=1, max_length=1024) + commit: CommitSide + + +class RepositoryContext(BaseModel): + """Source provenance needed to route child reads; never substitutes for native JSON.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + repository_id: NativeId + owner_id: NativeId + full_name: str = Field(pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$") + + +class IssuePayload(BaseModel): + """Unmodified issue and PR originals plus verified repository provenance.""" + + model_config = ConfigDict(extra="forbid") + issue: dict[str, JsonValue] + repository: RepositoryContext + pull_request_detail: dict[str, JsonValue] | None = None + + +class BranchPayload(BaseModel): + """Unmodified branch original plus verified repository provenance.""" + + model_config = ConfigDict(extra="forbid") + branch: dict[str, JsonValue] + repository: RepositoryContext + + +class RootProgress(BaseModel): + """Position in the fixed authorized repository selection.""" + + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + repository_index: int = Field(default=0, ge=0) + + +class ListProgress(BaseModel): + """Native page position and optional pinned PR diff identity.""" + + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + page: int = Field(default=1, ge=1) + count: int = Field(default=0, ge=0) + head_sha: Sha | None = None + base_sha: Sha | None = None + + +class TreeFrame(BaseModel): + """A DFS frame holds one path component, avoiding repeated full paths at every depth.""" + + model_config = ConfigDict(extra="forbid") + sha: Sha + component: str = Field(max_length=4096) + offset: int = Field(default=0, ge=0) + + +class FileProgress(BaseModel): + """Pinned immutable tree plus bounded DFS position, validated again by ScanContinuation.""" + + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + ref: str | None = None + commit_sha: Sha | None = None + tree_sha: Sha | None = None + stack: tuple[TreeFrame, ...] = () + initialized: StrictBool = False + + @model_validator(mode="after") + def coherent_snapshot(self) -> "FileProgress": + """Malformed progress must fail before provider I/O or absence reconciliation.""" + pinned = (self.ref, self.commit_sha, self.tree_sha) + if self.initialized: + if any(value is None for value in pinned): + raise ValueError("Initialized GitHub code progress requires a complete snapshot") + if self.stack and (self.stack[0].sha != self.tree_sha or self.stack[0].component): + raise ValueError("GitHub DFS root does not match the pinned tree") + elif any(value is not None for value in pinned) or self.stack: + raise ValueError("Uninitialized GitHub code progress cannot contain a snapshot") + return self + + +class Dates(NativeModel): + """Optional upstream timestamps, never substituted with local observation time.""" + + created_at: AwareDatetime | None = None + updated_at: AwareDatetime | None = None diff --git a/backend/airweave/platform/sources/gitlab.py b/backend/airweave/platform/sources/gitlab.py index 9620c8a828..dac0953bad 100644 --- a/backend/airweave/platform/sources/gitlab.py +++ b/backend/airweave/platform/sources/gitlab.py @@ -31,12 +31,12 @@ _require_gl_datetime, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.platform.utils.file_extensions import ( get_language_for_extension, is_text_file, diff --git a/backend/airweave/platform/sources/gmail.py b/backend/airweave/platform/sources/gmail.py index 57819cc3bc..8acb1765ff 100644 --- a/backend/airweave/platform/sources/gmail.py +++ b/backend/airweave/platform/sources/gmail.py @@ -1,27 +1,48 @@ -"""Gmail source implementation for syncing email threads, messages, and attachments. +"""Gmail originals through the canonical page engine and native history checkpoints. -Uses concurrent/batching processing for optimal performance: - * Thread detail fetch + per-thread processing - * Per-thread message processing - * Per-message attachment fetch & processing - * Incremental history message-detail fetch +The upstream entity extractor remains available for legacy consumers; owned capture +uses one fenced message/blob authority and independent derived projection. """ from __future__ import annotations import asyncio import base64 +import hashlib +import json from datetime import datetime from typing import Any, AsyncGenerator, Dict, List, Optional, Set import httpx -from tenacity import retry, stop_after_attempt +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from tenacity import retry, retry_if_exception, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import ( + AuthProviderRateLimitError, + AuthProviderServerError, +) from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import SourceAuthError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidScanContinuation, +) +from airweave.domains.entities.canonical.requests import CaptureRecord, CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import ( + SourceAuthError, + SourceError, + SourceRateLimitError, + SourceServerError, +) +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage import FileSkippedException from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor @@ -36,28 +57,62 @@ GmailThreadEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.bounded_response import bounded_response_bytes +from airweave.platform.http_client.retry_helpers import ( wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.gmail_capture import GmailCapture +from airweave.platform.sources.gmail_errors import GmailThrottleError, raise_gmail_throttle +from airweave.platform.sources.gmail_pages import GmailPages, invalid_page_token +from airweave.platform.sources.http_helpers import raise_for_status from airweave.platform.utils.filename_utils import safe_filename from airweave.schemas.source_connection import AuthenticationMethod, OAuthType def _should_retry_gmail_request(exception: Exception) -> bool: """Custom retry condition that excludes 404 errors but includes 429 and timeouts.""" + if isinstance(exception, GmailThrottleError): + return exception.retry_after is None or exception.retry_after <= 120 + if isinstance(exception, (SourceRateLimitError, AuthProviderRateLimitError)): + # The shared wait caps at 120s. Defer a longer provider minimum instead + # of retrying before it or extending this request's bounded wait. + return exception.retry_after <= 120 if isinstance(exception, httpx.HTTPStatusError): - if exception.response.status_code == 404: - return False if exception.response.status_code == 429: - return True - return True - if isinstance(exception, (httpx.ConnectTimeout, httpx.ReadTimeout)): + try: + return float(exception.response.headers.get("Retry-After", "0")) <= 120 + except ValueError: + return True # Preserve the shared helper's fallback for invalid headers. + return exception.response.status_code >= 500 + if isinstance( + exception, + ( + httpx.ConnectTimeout, + httpx.ReadTimeout, + SourceServerError, + AuthProviderServerError, + ), + ): return True return False +def _wait_gmail_request(retry_state) -> float: + """Honor native quota timing; otherwise reuse the existing bounded backoff.""" + error = retry_state.outcome.exception() + if isinstance(error, GmailThrottleError) and error.retry_after is not None: + return max(1.0, error.retry_after) + return wait_rate_limit_with_backoff(retry_state) + + +class GmailProfile(BaseModel): + """Native mailbox identity; unrelated profile fields remain provider-owned.""" + + model_config = ConfigDict(extra="ignore") + emailAddress: str = Field(pattern=r"^[^\s@]+@[^\s@]+$") + + @source( name="Gmail", short_name="gmail", @@ -83,6 +138,90 @@ class GmailSource(BaseSource): It supports syncing email threads, individual messages, and file attachments. """ + canonical_record_types = ("message",) + + canonical_container_parents = {} + capture_config: GmailConfig + _verified_mailbox: str | None = None + + def _require_mailbox(self) -> None: + """Direct capture calls cannot bypass native identity verification.""" + expected = self.capture_config.expected_mailbox + if expected is None or self._verified_mailbox != expected.casefold(): + raise ValueError("Owned Gmail capture requires an attested mailbox identity") + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Bind the actual query and typed filters; preserve every existing filtered scope.""" + self._require_mailbox() + query = self._build_gmail_query() + fingerprint = hashlib.sha256( + json.dumps( + { + "capture_version": 2, + "query": query, + "filters": self.capture_config.model_dump(mode="json"), + }, + sort_keys=True, + ).encode() + ).hexdigest() + return CycleConfiguration( + fingerprint=fingerprint, + parents={"message": (None,)}, + known_object_validation=() if query else ("message",), + ) + + def _canonical_capture(self, files: FileService | None = None) -> GmailCapture: + self._require_mailbox() + return GmailCapture( + self._get_capture_json, + self._build_gmail_query(), + files=files, + attachment_get=self._get_mime_json, + ) + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Plan full mailbox capture or changes from verified canonical progress.""" + return await GmailPages(self._canonical_capture()).prepare( + previous, self.capture_cycle_configuration + ) + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """Resume the cycle's original mailbox boundary, never a fresh profile value.""" + self._require_mailbox() + return GmailPages.initial(cycle) + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Native messages enter the same page transaction as their continuation.""" + if scope != CompletedScope(record_type="message") or parent is not None: + raise ValueError("Gmail requires its independent message scope") + return await GmailPages(self._canonical_capture(files)).page(continuation) + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Exact current GET proves unfiltered mailbox membership, including known omissions.""" + if ( + self._build_gmail_query() + or record.identity.record_type != "message" + or record.parent is not None + ): + raise ValueError("Exact Gmail omission refresh requires unfiltered mailbox messages") + return await self._canonical_capture(files).message(record.identity.native_id) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Mail messages have no canonical child scopes.""" + raise ValueError("Gmail has no child capture scopes") + + async def confirm_absent(self, record: SourceRecord) -> None: + """Unfiltered omissions require exact hydration; filtered absence describes only scope.""" + raise ValueError("Gmail omission must use declared exact known-object validation") + # ----------------------- # Construction / Config # ----------------------- @@ -90,7 +229,7 @@ class GmailSource(BaseSource): async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: GmailConfig, @@ -98,6 +237,7 @@ async def create( """Create a new Gmail source instance.""" instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.capture_config = config config_dict = config.model_dump() if config else {} instance.batch_size = int(config_dict.get("batch_size", 30)) instance.max_queue_size = int(config_dict.get("max_queue_size", 200)) @@ -111,6 +251,8 @@ async def create( "excluded_categories", ["promotions", "social"] ) instance.gmail_query = config_dict.get("gmail_query") + if config.expected_mailbox is not None: + await instance.validate() return instance @@ -230,18 +372,16 @@ def _message_matches_label_filters(self, message_data: Dict) -> bool: async def _authed_headers(self) -> Dict[str, str]: """Build Authorization headers with a fresh token.""" - token = await self.auth.get_token() - return {"Authorization": f"Bearer {token}"} + return await authorization_headers(self.auth) async def _refresh_and_get_headers(self) -> Dict[str, str]: """Force-refresh the token and return updated headers.""" - new_token = await self.auth.force_refresh() - return {"Authorization": f"Bearer {new_token}"} + return await authorization_headers(self.auth, refresh=True) @retry( stop=stop_after_attempt(5), - retry=_should_retry_gmail_request, - wait=wait_rate_limit_with_backoff, + retry=retry_if_exception(_should_retry_gmail_request), + wait=_wait_gmail_request, reraise=True, ) async def _get(self, url: str, params: Optional[dict] = None) -> dict: @@ -259,11 +399,9 @@ async def _get(self, url: str, params: Optional[dict] = None) -> dict: response = await self.http_client.get(url, headers=headers, params=params) if response.status_code == 429: - self.logger.warning( - f"Got 429 Rate Limited from Gmail API. Headers: {response.headers}. " - f"Body: {response.text}." - ) + self.logger.warning("Gmail rate limit reached") + raise_gmail_throttle(response) raise_for_status( response, source_short_name=self.short_name, @@ -274,6 +412,68 @@ async def _get(self, url: str, params: Optional[dict] = None) -> dict: self.logger.debug(f"Response data keys: {list(data.keys())}") return data + @retry( + stop=stop_after_attempt(5), + retry=retry_if_exception(_should_retry_gmail_request), + wait=_wait_gmail_request, + reraise=True, + ) + async def _get_capture_json(self, url: str, params: Optional[dict] = None) -> dict: + """Bound canonical JSON responses while streaming; native failures remain visible.""" + return await self._get_mime_json(url, max_bytes=32 * 1024 * 1024, params=params) + + async def _get_mime_json( + self, url: str, *, max_bytes: int, params: Optional[dict] = None + ) -> dict: + """Read bounded attachment JSON through the managed source HTTP transport.""" + headers = {**await self._authed_headers(), "Accept-Encoding": "identity"} + for attempt in range(2): + async with self.http_client.stream( + "GET", url, headers=headers, params=params + ) as response: + if response.status_code == 401 and attempt == 0 and self.auth.supports_refresh: + headers = { + **await self._refresh_and_get_headers(), + "Accept-Encoding": "identity", + } + continue + if response.headers.get("content-encoding", "identity").lower() != "identity": + raise SourceError( + "Gmail did not honor uncompressed bounded response request", + source_short_name="gmail", + ) + maximum = max_bytes if response.is_success else min(max_bytes, 65536) + body = await self._bounded_response_bytes(response, maximum) + buffered = httpx.Response( + response.status_code, + headers=response.headers, + request=response.request, + content=bytes(body), + ) + if response.status_code == 400 and params and params.get("pageToken"): + try: + native_error = json.loads(body) + except (ValueError, UnicodeError): + native_error = None + if isinstance(native_error, dict) and invalid_page_token(native_error): + raise InvalidScanContinuation("Gmail rejected its saved page token") + raise_gmail_throttle(buffered) + raise_for_status( + buffered, + source_short_name=self.short_name, + token_provider_kind=self.auth.provider_kind, + ) + value = json.loads(body) + if not isinstance(value, dict): + raise ValueError("Gmail response must be an object") + return value + raise AssertionError("Unreachable Gmail refresh state") + + @staticmethod + async def _bounded_response_bytes(response: httpx.Response, maximum: int) -> bytes: + """Own the raw iterator; identity encoding makes this a decoded-content bound too.""" + return await bounded_response_bytes(response, maximum, label="Gmail response") + # ----------------------- # Cursor helper # ----------------------- @@ -894,5 +1094,18 @@ async def generate_entities( raise async def validate(self) -> None: - """Validate credentials by pinging the Gmail user profile.""" - await self._get("https://gmail.googleapis.com/gmail/v1/users/me/profile") + """Re-attest the native mailbox before permitting owned capture.""" + self._verified_mailbox = None + raw = await self._get_capture_json( + "https://gmail.googleapis.com/gmail/v1/users/me/profile", + params={"fields": "emailAddress"}, + ) + try: + profile = GmailProfile.model_validate(raw) + except ValidationError: + raise ValueError("Gmail returned an invalid mailbox identity") from None + expected = self.capture_config.expected_mailbox + if expected is not None: + if profile.emailAddress.casefold() != expected.casefold(): + raise ValueError("Gmail mailbox identity does not match the trusted binding") + self._verified_mailbox = expected.casefold() diff --git a/backend/airweave/platform/sources/gmail_capture.py b/backend/airweave/platform/sources/gmail_capture.py new file mode 100644 index 0000000000..b0d39d0332 --- /dev/null +++ b/backend/airweave/platform/sources/gmail_capture.py @@ -0,0 +1,270 @@ +"""Original Gmail message capture, independent of derived thread/search entities.""" + +import base64 +import hashlib +import json +from collections.abc import Awaitable, Callable, Iterator +from datetime import datetime, timezone + +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from airweave.domains.entities.canonical.page_source import InvalidScanContinuation +from airweave.domains.entities.canonical.requests import ( + BlobReference, + CaptureRecord, + RecordIdentity, +) +from airweave.domains.sources.exceptions import SourceEntityNotFoundError, SourceError +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService + +BASE = "https://gmail.googleapis.com/gmail/v1/users/me" +Get = Callable[..., Awaitable[dict]] + + +class _HistoryMessage(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(min_length=1) + + +class _HistoryChange(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + message: _HistoryMessage + + +class _HistoryEntry(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + messages: list[_HistoryMessage] = Field(default_factory=list) + messagesAdded: list[_HistoryChange] = Field(default_factory=list) + messagesDeleted: list[_HistoryChange] = Field(default_factory=list) + labelsAdded: list[_HistoryChange] = Field(default_factory=list) + labelsRemoved: list[_HistoryChange] = Field(default_factory=list) + + +class _HistoryResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + history: list[_HistoryEntry] = Field(default_factory=list) + historyId: str = Field(min_length=1) + nextPageToken: str | None = Field(default=None, min_length=1) + + +class HistoryPage(BaseModel): + """One provider page; its mailbox boundary is not a committed checkpoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + message_ids: tuple[str, ...] + history_id: str + next_page_token: str | None + fingerprint: str + + +class HydratedMessages(BaseModel): + """Bounded current observations; callers atomically commit records and offset.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + records: tuple[CaptureRecord, ...] = Field(max_length=500) + next_offset: int + complete: bool + + +def parse_history_page(raw: dict) -> HistoryPage: + """Normalize only one page, preserving first occurrence order across all event arrays.""" + response = _HistoryResponse.model_validate(raw) + affected: dict[str, None] = {} + for entry in response.history: + for message in entry.messages: + affected[message.id] = None + for changes in ( + entry.messagesAdded, + entry.messagesDeleted, + entry.labelsAdded, + entry.labelsRemoved, + ): + for change in changes: + affected[change.message.id] = None + ids = tuple(affected) + # Intermediate mailbox historyId can advance without changing this page. + # Terminal pages bind the candidate checkpoint to the exact hydrated response. + # Include native events: a later edit can leave deduplicated IDs unchanged. + fingerprint = hashlib.sha256( + json.dumps( + [ + raw.get("history", []), + response.nextPageToken, + response.historyId if response.nextPageToken is None else None, + ], + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + ).hexdigest() + return HistoryPage( + message_ids=ids, + history_id=response.historyId, + next_page_token=response.nextPageToken, + fingerprint=fingerprint, + ) + + +def _external_parts(part: dict, path: str = "/payload") -> Iterator[tuple[dict, str]]: + """Keep a JSON Pointer to each external MIME body in untouched provider JSON.""" + if part.get("body", {}).get("attachmentId"): + yield part, path + "/body" + for index, child in enumerate(part.get("parts", [])): + yield from _external_parts(child, f"{path}/parts/{index}") + + +class GmailCapture: + """Fetch raw provider messages with explicit reconciliation and cursor barriers.""" + + def __init__( + self, + get: Get, + query: str | None, + *, + files: FileService | None = None, + attachment_get: Get | None = None, + ): + """Reuse the source managed HTTP/auth transport and exact provider query.""" + self.get = get + self.query = query + self.files = files + self.attachment_get = attachment_get + + async def message(self, message_id: str) -> CaptureRecord: + """Observe current state; a disappeared message is a provider deletion.""" + identity = RecordIdentity(record_type="message", native_id=message_id) + try: + payload = await self.get(f"{BASE}/messages/{message_id}", params={"format": "full"}) + except (httpx.HTTPStatusError, SourceEntityNotFoundError) as exc: + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code != 404: + raise + return CaptureRecord( + identity=identity, + payload={"id": message_id}, + kind="delete", + removal_reason="provider_deleted", + observed_at=datetime.now(timezone.utc), + ) + if payload.get("id") != message_id: + raise ValueError("Gmail message response identity mismatch") + blobs, complete = await self.capture_bodies(message_id, payload) + created = payload.get("internalDate") + return CaptureRecord( + identity=identity, + payload=payload, + observed_at=datetime.now(timezone.utc), + source_created_at=( + datetime.fromtimestamp(int(created) / 1000, timezone.utc) + if created is not None + else None + ), + completeness="complete" if complete else "partial", + blobs=blobs, + ) + + async def capture_bodies( + self, + message_id: str, + payload: dict, + ) -> tuple[tuple[BlobReference, ...], bool]: + """Persist MIME bytes before their record; only deliberate size skips are partial.""" + blobs = [] + complete = True + for part, path in _external_parts(payload.get("payload", {})): + if self.files is None or self.attachment_get is None: + complete = False + continue + try: + content = await self.body_bytes(message_id, part["body"]) + blob = await self.files.store_canonical_blob( + content, media_type=part.get("mimeType") + ) + except FileSkippedException: + complete = False + continue + blobs.append( + BlobReference.model_validate( + { + **blob.model_dump(), + "source_path": path, + "filename": part.get("filename") or None, + } + ) + ) + return tuple(blobs), complete + + async def body_bytes(self, message_id: str, body: dict) -> bytes: + """Validate declared, encoded and decoded lengths before accepting provider bytes.""" + maximum = self.files.MAX_FILE_SIZE_BYTES + if int(body.get("size", 0)) > maximum: + raise FileSkippedException("MIME body exceeds size limit", "MIME body") + encoded_limit = ((maximum + 2) // 3) * 4 + result = await self.attachment_get( + f"{BASE}/messages/{message_id}/attachments/{body['attachmentId']}", + max_bytes=encoded_limit + 4096, + ) + encoded = result["data"] + if len(encoded) > encoded_limit: + raise FileSkippedException("Encoded MIME body exceeds size limit", "MIME body") + content = base64.b64decode( + encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True + ) + if len(content) > maximum: + raise FileSkippedException("Decoded MIME body exceeds size limit", "MIME body") + if len(content) != int(result["size"]) or len(content) != int(body["size"]): + raise ValueError("Gmail MIME body size does not match provider metadata") + return content + + async def history_page( + self, boundary: str, token: str | None = None, *, max_results: int = 500 + ) -> HistoryPage: + """Fetch one history page; boundaries stay opaque and no cursor is advanced.""" + if not boundary or not 1 <= max_results <= 500: + raise ValueError("History requires a boundary and a page size from 1 to 500") + params = {"startHistoryId": boundary, "maxResults": max_results} + if token is not None: + params["pageToken"] = token + return parse_history_page(await self.get(f"{BASE}/history", params=params)) + + async def hydrate_history_page( + self, + page: HistoryPage, + *, + offset: int = 0, + limit: int = 500, + expected_fingerprint: str | None = None, + ) -> HydratedMessages: + """Reuse exact current reads, never apply stale history deletion/label payloads.""" + if expected_fingerprint is not None and page.fingerprint != expected_fingerprint: + raise InvalidScanContinuation("Gmail history page changed before offset replay") + if offset and expected_fingerprint is None: + raise ValueError("Resuming Gmail history hydration requires its page fingerprint") + if not 0 <= offset <= len(page.message_ids) or not 1 <= limit <= 500: + raise ValueError("Invalid Gmail history hydration offset or batch size") + return await self.hydrate_messages(page.message_ids, offset=offset, limit=limit) + + async def hydrate_messages( + self, message_ids: tuple[str, ...], *, offset: int = 0, limit: int = 50 + ) -> HydratedMessages: + """Stop at complete originals; bounded byte targets never truncate native content.""" + if not 0 <= offset <= len(message_ids) or not 1 <= limit <= 500: + raise ValueError("Invalid Gmail message hydration boundary") + records = [] + total_bytes = 0 + for mid in message_ids[offset : offset + limit]: + record = await self.message(mid) + size = len(record.model_dump_json().encode()) + if size > 32 * 1024 * 1024: + raise SourceError( + "Gmail original exceeds the 32 MiB record limit", source_short_name="gmail" + ) + records.append(record) + total_bytes += size + if total_bytes >= 8 * 1024 * 1024: + break + end = offset + len(records) + return HydratedMessages( + records=tuple(records), next_offset=end, complete=end == len(message_ids) + ) diff --git a/backend/airweave/platform/sources/gmail_errors.py b/backend/airweave/platform/sources/gmail_errors.py new file mode 100644 index 0000000000..11742952be --- /dev/null +++ b/backend/airweave/platform/sources/gmail_errors.py @@ -0,0 +1,75 @@ +"""Gmail's structured 403 quota reasons, separate from permission denial.""" + +import math +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from airweave.domains.sources.exceptions import SourceServerError + + +class _Reason(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + domain: str + reason: str + + +class _Error(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + code: int + errors: list[_Reason] = Field(min_length=1) + + +class _Envelope(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + error: _Error + + +class GmailThrottleError(SourceServerError): + """Native 403 throttle; unknown provider delay remains unknown.""" + + status_code = 403 + + def __init__(self, retry_after: float | None): + """Retain only safe native timing, never provider bodies or request URLs.""" + self.retry_after = retry_after + super().__init__( + "Gmail rate limit exceeded (403); capture remains resumable", + source_short_name="gmail", + status_code=403, + ) + + +def _retry_after(value: str | None) -> float | None: + if value is None: + return None + try: + seconds = float(value) + except ValueError: + try: + date = parsedate_to_datetime(value) + if date.tzinfo is None: + return None + seconds = (date - datetime.now(timezone.utc)).total_seconds() + except (ValueError, TypeError, OverflowError): + return None + return max(0.0, seconds) if math.isfinite(seconds) else None + + +def raise_gmail_throttle(response: httpx.Response) -> None: + """Retry only documented transient quota reasons, not mixed or unknown errors.""" + if response.status_code != 403: + return + try: + envelope = _Envelope.model_validate_json(response.content) + except ValidationError: + return + if envelope.error.code != 403 or not all( + item.domain == "usageLimits" + and item.reason in {"rateLimitExceeded", "userRateLimitExceeded"} + for item in envelope.error.errors + ): + return + raise GmailThrottleError(_retry_after(response.headers.get("Retry-After"))) diff --git a/backend/airweave/platform/sources/gmail_pages.py b/backend/airweave/platform/sources/gmail_pages.py new file mode 100644 index 0000000000..1620c43048 --- /dev/null +++ b/backend/airweave/platform/sources/gmail_pages.py @@ -0,0 +1,268 @@ +"""Gmail native page acquisition; the canonical engine owns every durable acknowledgement.""" + +import hashlib +from typing import Annotated, Literal + +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CycleConfiguration, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidCaptureCheckpoint, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceEntityNotFoundError +from airweave.platform.sources.gmail_capture import BASE, GmailCapture + +PAGE_SIZE = 50 +MessageID = Annotated[str, Field(min_length=1, max_length=128)] + + +class _MessageID(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + id: MessageID + + +class _ListPage(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + messages: list[_MessageID] = Field(default_factory=list, max_length=PAGE_SIZE) + nextPageToken: str | None = Field(default=None, min_length=1, max_length=8192) + + +class _ParameterError(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + location: str | None = None + locationType: str | None = None + reason: str | None = None + + +class _ErrorBody(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + errors: list[_ParameterError] = Field(default_factory=list) + + +class _ErrorEnvelope(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + error: _ErrorBody + + +def invalid_page_token(raw: dict) -> bool: + """Only a native parameter-specific error authorizes page-token recovery.""" + try: + body = _ErrorEnvelope.model_validate(raw) + except ValidationError: + return False + return any( + item.location == "pageToken" + and item.locationType == "parameter" + and item.reason in {"badRequest", "invalidArgument"} + for item in body.error.errors + ) + + +class _Boundary(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + history_id: str = Field(min_length=1, max_length=1024) + + +class _Progress(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[1] = 1 + mode: Literal["full", "changes"] + phase: Literal["list", "history", "done"] + history_boundary: str | None = Field(default=None, max_length=1024) + list_token: str | None = Field(default=None, max_length=8192) + listing_complete: bool = False + pending_ids: tuple[MessageID, ...] = Field(default=(), max_length=PAGE_SIZE) + history_token: str | None = Field(default=None, max_length=8192) + history_fingerprint: str | None = None + history_offset: int = Field(default=0, ge=0) + list_tokens: tuple[str, ...] = Field(default=(), max_length=128) + history_tokens: tuple[str, ...] = Field(default=(), max_length=128) + + @model_validator(mode="after") + def valid_phase(self): + if self.phase == "history" and not self.history_boundary: + raise ValueError("History traversal requires its immutable start boundary") + if self.history_offset and not self.history_fingerprint: + raise ValueError("History offset requires an exact replay page fingerprint") + if self.pending_ids and self.phase != "list": + raise ValueError("Only a baseline list page may retain pending native IDs") + if len(set(self.pending_ids)) != len(self.pending_ids): + raise ValueError("Pending Gmail identities must be unique") + if self.listing_complete and self.list_token is not None: + raise ValueError("Completed listing cannot retain a next-page token") + if self.mode == "changes" and (not self.listing_complete or self.phase == "list"): + raise ValueError("Changes cannot enumerate a mailbox inventory") + return self + + def continuation(self) -> ScanContinuation: + validated = type(self).model_validate(self.model_dump()) + return ScanContinuation(value=validated.model_dump(mode="json")) + + +def _next_token(token: str | None, recent: tuple[str, ...]) -> tuple[str, ...]: + """Detect recent cursor loops without imposing a total page ceiling.""" + if token is None: + return recent + digest = hashlib.sha256(token.encode()).hexdigest() + if digest in recent: + raise ValueError("Gmail pagination repeated a recent cursor") + return (*recent[-127:], digest) + + +class GmailPages: + """Small source-local acquisition functions; no cursor writes, SQL, or background workers.""" + + def __init__(self, capture: GmailCapture): + """Reuse native message/MIME acquisition and the source transport.""" + self.capture = capture + + async def prepare( + self, previous: CaptureCycle | None, configuration: CycleConfiguration + ) -> CapturePlan: + """A legacy cursor never substitutes for compatible canonical full-capture evidence.""" + if self.capture.query: + return CapturePlan() + if ( + previous is not None + and previous.configuration == configuration + and ( + previous.last_full_capture is not None and previous.promoted_checkpoint is not None + ) + ): + return CapturePlan( + mode="changes", starting_checkpoint=previous.promoted_checkpoint.checkpoint + ) + raw = await self.capture.get(f"{BASE}/profile") + boundary = _Boundary(history_id=raw["historyId"]) + return CapturePlan(starting_checkpoint=ProviderCheckpoint(value=boundary.model_dump())) + + @staticmethod + def initial(cycle: CaptureCycle) -> ScanContinuation: + """Only the persisted cycle decides the first page and history boundary.""" + boundary = ( + _Boundary.model_validate(cycle.starting_checkpoint.value).history_id + if cycle.starting_checkpoint + else None + ) + return _Progress( + mode=cycle.mode, + phase="history" if cycle.mode == "changes" else "list", + history_boundary=boundary, + listing_complete=cycle.mode == "changes", + ).continuation() + + async def page(self, continuation: ScanContinuation) -> CapturePage: + """Return one bounded fully hydrated batch, without acknowledging it.""" + state = _Progress.model_validate(continuation.value) + if state.phase == "done": + raise ValueError("A completed Gmail page must not be fetched again") + if bool(self.capture.query) != (state.history_boundary is None): + raise ValueError("Gmail continuation belongs to a different query mode") + return await self._list(state) if state.phase == "list" else await self._history(state) + + async def _list(self, state: _Progress) -> CapturePage: + if state.pending_ids: + return await self._hydrate_list(state) + params = {"maxResults": PAGE_SIZE, "includeSpamTrash": "true"} + if self.capture.query: + params["q"] = self.capture.query + if state.list_token is not None: + params["pageToken"] = state.list_token + raw = await self.capture.get(f"{BASE}/messages", params=params) + listing = _ListPage.model_validate(raw) + tokens = _next_token(listing.nextPageToken, state.list_tokens) + identities = tuple(item.id for item in listing.messages) + if len(set(identities)) != len(identities): + raise ValueError("Gmail list response duplicated a native message identity") + state = state.model_copy( + update={ + "list_token": listing.nextPageToken, + "listing_complete": listing.nextPageToken is None, + "list_tokens": tokens, + "pending_ids": identities, + } + ) + return await self._hydrate_list(state) + + async def _hydrate_list(self, state: _Progress) -> CapturePage: + batch = await self.capture.hydrate_messages(state.pending_ids) + pending = state.pending_ids[batch.next_offset :] + final = bool(self.capture.query) and state.listing_complete and not pending + following = state.model_copy( + update={ + "pending_ids": pending, + "phase": "list" + if pending + else "done" + if final + else "list" + if self.capture.query + else "history", + } + ) + return CapturePage( + records=batch.records, continuation=following.continuation(), final=final + ) + + async def _history(self, state: _Progress) -> CapturePage: + try: + page = await self.capture.history_page( + state.history_boundary, state.history_token, max_results=100 + ) + except (SourceEntityNotFoundError, httpx.HTTPStatusError) as error: + if isinstance(error, httpx.HTTPStatusError) and error.response.status_code != 404: + raise + raise InvalidCaptureCheckpoint( + "Gmail history boundary expired; full capture required" + ) from error + batch = await self.capture.hydrate_history_page( + page, + offset=state.history_offset, + limit=PAGE_SIZE, + expected_fingerprint=state.history_fingerprint, + ) + if not batch.complete: + following = state.model_copy( + update={ + "history_offset": batch.next_offset, + "history_fingerprint": page.fingerprint, + } + ) + return CapturePage(records=batch.records, continuation=following.continuation()) + if page.next_page_token is not None: + following = state.model_copy( + update={ + "history_offset": 0, + "history_fingerprint": None, + "history_token": page.next_page_token, + "history_tokens": _next_token(page.next_page_token, state.history_tokens), + } + ) + return CapturePage(records=batch.records, continuation=following.continuation()) + final = state.listing_complete + following = state.model_copy( + update={ + "phase": "done" if final else "list", + "history_boundary": page.history_id, + "history_token": None, + "history_fingerprint": None, + "history_offset": 0, + "history_tokens": (), + } + ) + return CapturePage( + records=batch.records, + continuation=following.continuation(), + final=final, + provider_checkpoint=ProviderCheckpoint(value={"history_id": page.history_id}) + if final + else None, + ) diff --git a/backend/airweave/platform/sources/google_calendar.py b/backend/airweave/platform/sources/google_calendar.py index 86d9f17d3a..eb8cc4f86f 100644 --- a/backend/airweave/platform/sources/google_calendar.py +++ b/backend/airweave/platform/sources/google_calendar.py @@ -1,51 +1,51 @@ -"""Google Calendar source implementation. - -Retrieves data from a user's Google Calendar (read-only mode): - - CalendarList entries (the user's list of calendars) - - Each underlying Calendar resource - - Events belonging to each Calendar - - (Optionally) Free/Busy data for each Calendar - -Follows the same structure and pattern as other connector implementations -(e.g., Gmail, Asana, Todoist, HubSpot). The entity schemas are defined in -entities/google_calendar.py. - -Reference: - https://developers.google.com/calendar/api/v3/reference - -Now supports two flows: - - Non-batching / sequential (default): preserves original behavior. - - Batching / concurrent (opt-in): gated by `batch_generation` config and uses the - bounded-concurrency driver in BaseSource across all major I/O points: - * Per-calendar Calendar resource fetch - * Per-calendar event listing (still sequential within a calendar due to pagination) - * Per-calendar Free/Busy fetch - -Config (all optional, shown with defaults): - { - "batch_generation": False, # enable/disable concurrent generation - "batch_size": 30, # max concurrent workers (calendars processed in parallel) - "max_queue_size": 200, # backpressure queue size - "preserve_order": False, # maintain calendar order when yielding results - "stop_on_error": False # cancel all on first error - } +"""Google Calendar originals with durable per-calendar capture and observed range reads. + +Canonical capture uses the shared page engine: complete calendar membership, +per-calendar full or incremental raw events, and a fixed expanded-instance window. +The engine owns every page acknowledgement and completed scope publication. + +The upstream BaseSource entity interface remains implemented below; it is not the +canonical cursor authority. Configured native calendar IDs and occurrence windows +are validated by GoogleCalendarConfig. """ from __future__ import annotations +import json import urllib.parse from datetime import datetime, timedelta from typing import Any, AsyncGenerator, Dict, List, Optional -from tenacity import retry, stop_after_attempt +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from tenacity import retry, retry_if_exception, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import ( + AuthProviderRateLimitError, + AuthProviderServerError, +) from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidScanContinuation, +) +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation, ScanState +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.sources.exceptions import SourceError, SourceRateLimitError +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.cursors.google_calendar import GoogleCalendarCursor from airweave.platform.decorators import source from airweave.platform.entities._base import BaseEntity, Breadcrumb from airweave.platform.entities.google_calendar import ( @@ -55,15 +55,39 @@ GoogleCalendarListEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.bounded_response import bounded_response_bytes +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, + should_retry_on_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.records.calendar_pages import CalendarPages, invalid_page_token from airweave.schemas.source_connection import AuthenticationMethod, OAuthType +def _retry_capture(exception: BaseException) -> bool: + """Do not retry earlier than a provider's minimum beyond this request wait budget.""" + if isinstance(exception, (SourceRateLimitError, AuthProviderRateLimitError)): + return exception.retry_after <= 120 + if isinstance(exception, AuthProviderServerError): + return True + if isinstance(exception, httpx.HTTPStatusError) and exception.response.status_code == 429: + try: + return float(exception.response.headers.get("Retry-After", "0")) <= 120 + except ValueError: + return True + return should_retry_on_rate_limit_or_timeout(exception) + + +class CalendarPrincipal(BaseModel): + """Primary calendar resource identity, not a Google OIDC subject.""" + + model_config = ConfigDict(extra="ignore") + id: str = Field(min_length=1, pattern=r"^\S+$") + + @source( name="Google Calendar", short_name="google_calendar", @@ -77,7 +101,8 @@ auth_config_class=None, config_class=GoogleCalendarConfig, labels=["Productivity", "Calendar"], - supports_continuous=False, + supports_continuous=True, + cursor_class=GoogleCalendarCursor, rate_limit_level=RateLimitLevel.ORG, ) class GoogleCalendarSource(BaseSource): @@ -89,6 +114,77 @@ class GoogleCalendarSource(BaseSource): Google Calendar scheduling information for productivity and time management insights. """ + _verified_primary_calendar_id: str | None = None + + def _require_principal(self) -> None: + """Guard direct capture and resume even outside shared lifecycle validation.""" + expected = self.calendar_config.expected_primary_calendar_id + if expected is None or self._verified_primary_calendar_id != expected: + raise ValueError( + "Owned Calendar capture requires an attested primary calendar identity" + ) + + def _calendar_pages(self) -> CalendarPages: + """Keep native page logic behind the same attestation gate.""" + self._require_principal() + return CalendarPages(self._get_capture_json, self.calendar_config) + + canonical_record_types = ("calendar", "event", "event_occurrence") + canonical_container_parents = {"event": "calendar", "event_occurrence": "calendar"} + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Calendar membership owns exact native event and occurrence scopes.""" + return self._calendar_pages().configuration + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Resolve the expansion window once per persisted cycle.""" + return self._calendar_pages().prepare() + + async def prepare_scope( + self, + scope: CompletedScope, + cycle: CaptureCycle, + previous: ScanState | None, + *, + parent: SourceRecord | None, + force_full: bool, + ) -> ScopePlan: + """Choose native delta only from compatible completed exact-scope evidence.""" + return self._calendar_pages().scope_plan( + scope, cycle, previous, parent=parent, force_full=force_full + ) + + def initial_scope_continuation( + self, scope: CompletedScope, cycle: CaptureCycle, plan: ScopePlan + ) -> ScanContinuation: + """Provider progress starts from the immutable engine-attested scope plan.""" + self._require_principal() + return CalendarPages.initial(plan) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Native calendar IDs remain the stable container namespace.""" + return CompletedScope( + record_type=record_type, container_id=parent.identity.native_id, parent=parent.identity + ) + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Originals and pagination commit together through the production page driver.""" + return await self._calendar_pages().page(scope, continuation) + + async def confirm_absent(self, record: SourceRecord) -> None: + """An accessible omitted calendar prevents premature membership reconciliation.""" + if record.identity.record_type != "calendar" or record.parent is not None: + raise ValueError("Calendar omission must identify root membership") + await self._calendar_pages().confirm_member_absent(record.identity.native_id) + # ----------------------- # Construction / Config # ----------------------- @@ -96,7 +192,7 @@ class GoogleCalendarSource(BaseSource): async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: GoogleCalendarConfig, @@ -104,12 +200,15 @@ async def create( """Create a new Google Calendar source instance.""" instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.calendar_config = config config_dict = config.model_dump() if config else {} instance.batch_generation = bool(config_dict.get("batch_generation", False)) instance.batch_size = int(config_dict.get("batch_size", 30)) instance.max_queue_size = int(config_dict.get("max_queue_size", 200)) instance.preserve_order = bool(config_dict.get("preserve_order", False)) instance.stop_on_error = bool(config_dict.get("stop_on_error", False)) + if config.expected_primary_calendar_id is not None: + await instance.validate() return instance @@ -119,13 +218,61 @@ async def create( async def _authed_headers(self) -> Dict[str, str]: """Build Authorization headers with a fresh token.""" - token = await self.auth.get_token() - return {"Authorization": f"Bearer {token}"} + return await authorization_headers(self.auth) async def _refresh_and_get_headers(self) -> Dict[str, str]: """Force-refresh the token and return updated headers.""" - new_token = await self.auth.force_refresh() - return {"Authorization": f"Bearer {new_token}"} + return await authorization_headers(self.auth, refresh=True) + + @retry( + stop=stop_after_attempt(5), + retry=retry_if_exception(_retry_capture), + wait=wait_rate_limit_with_backoff, + reraise=True, + ) + async def _get_capture_json(self, url: str, params: Optional[dict] = None) -> dict: + """Bound native capture responses, preserving auth refresh and typed errors.""" + headers = {**await self._authed_headers(), "Accept-Encoding": "identity"} + for attempt in range(2): + async with self.http_client.stream( + "GET", url, headers=headers, params=params + ) as response: + if response.status_code == 401 and attempt == 0 and self.auth.supports_refresh: + headers = { + **await self._refresh_and_get_headers(), + "Accept-Encoding": "identity", + } + continue + if response.headers.get("content-encoding", "identity").lower() != "identity": + raise SourceError( + "Calendar did not honor identity encoding", + source_short_name="google_calendar", + ) + maximum = 32 * 1024 * 1024 if response.is_success else 65536 + body = await bounded_response_bytes(response, maximum, label="Calendar response") + buffered = httpx.Response( + response.status_code, + headers=response.headers, + request=response.request, + content=body, + ) + if response.status_code == 400 and params and params.get("pageToken"): + try: + native_error = json.loads(body) + except (ValueError, UnicodeError): + native_error = None + if isinstance(native_error, dict) and invalid_page_token(native_error): + raise InvalidScanContinuation("Calendar rejected its saved page token") + raise_for_status( + buffered, + source_short_name=self.short_name, + token_provider_kind=self.auth.provider_kind, + ) + value = json.loads(body) + if not isinstance(value, dict): + raise ValueError("Calendar response must be an object") + return value + raise AssertionError("Unreachable Calendar refresh state") @retry( stop=stop_after_attempt(5), @@ -368,8 +515,23 @@ async def generate_entities( yield entity async def validate(self) -> None: - """Validate credentials by pinging the Calendar API calendarList endpoint.""" - await self._get( - "https://www.googleapis.com/calendar/v3/users/me/calendarList", - params={"maxResults": "1"}, + """Verify the bound primary calendar without inferring a Google subject.""" + self._verified_primary_calendar_id = None + expected = self.calendar_config.expected_primary_calendar_id + if expected is None: + # Legacy extraction retains its original credential check. + await self._get( + "https://www.googleapis.com/calendar/v3/users/me/calendarList", + params={"maxResults": "1"}, + ) + return + raw = await self._get_capture_json( + "https://www.googleapis.com/calendar/v3/calendars/primary", params={"fields": "id"} ) + try: + principal = CalendarPrincipal.model_validate(raw) + except ValidationError: + raise ValueError("Calendar returned an invalid primary identity") from None + if principal.id != expected: + raise ValueError("Calendar primary identity does not match the trusted binding") + self._verified_primary_calendar_id = expected diff --git a/backend/airweave/platform/sources/google_docs.py b/backend/airweave/platform/sources/google_docs.py index f7063f7f3e..4eeec1f0ec 100644 --- a/backend/airweave/platform/sources/google_docs.py +++ b/backend/airweave/platform/sources/google_docs.py @@ -34,12 +34,12 @@ from airweave.platform.entities._base import BaseEntity from airweave.platform.entities.google_docs import GoogleDocsDocumentEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/google_drive.py b/backend/airweave/platform/sources/google_drive.py index 18f3eefb58..0f0783dd14 100644 --- a/backend/airweave/platform/sources/google_drive.py +++ b/backend/airweave/platform/sources/google_drive.py @@ -16,17 +16,32 @@ from __future__ import annotations +import hashlib +import json from datetime import datetime from typing import Any, AsyncGenerator, Dict, List, Optional import httpx +from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError from tenacity import retry, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel from airweave.domains.browse_tree.types import NodeSelectionData +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidScanContinuation, +) +from airweave.domains.entities.canonical.requests import CaptureRecord, CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation from airweave.domains.sources.exceptions import SourceAuthError, SourceError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage import FileSkippedException from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor @@ -41,15 +56,32 @@ _parse_drive_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.records.google_drive_content import capture_file_content +from airweave.platform.sources.records.google_drive_pages import DrivePages, rejected_listing_token from airweave.schemas.source_connection import AuthenticationMethod, OAuthType +class DrivePrincipal(BaseModel): + """Native stable identity; display labels and email are not binding authority.""" + + model_config = ConfigDict(extra="ignore", strict=True) + permissionId: str = Field(min_length=1, pattern=r"^\S+$") + me: StrictBool + + +class DriveAbout(BaseModel): + """Only the requested native principal response is interpreted.""" + + model_config = ConfigDict(extra="ignore", strict=True) + user: DrivePrincipal + + @source( name="Google Drive", short_name="google_drive", @@ -77,11 +109,108 @@ class GoogleDriveSource(BaseSource): while maintaining proper organization and access permissions. """ + canonical_record_types = ("file",) + + canonical_container_parents = {} + expected_permission_id: str | None = None + capture_native_sheets: bool = False + _verified_permission_id: str | None = None + + def _require_principal(self) -> None: + """Direct capture and resume cannot bypass native account attestation.""" + if ( + self.expected_permission_id is None + or self._verified_permission_id != self.expected_permission_id + ): + raise ValueError("Owned Drive capture requires an attested permission identity") + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """One unfiltered accessible corpus; legacy path selections remain unsupported.""" + self._require_principal() + if self.include_patterns: + raise ValueError("Drive path selection is not yet supported by canonical capture") + return CycleConfiguration( + fingerprint=hashlib.sha256( + json.dumps( + { + "capture_version": 3, + "permission_id": self.expected_permission_id, + "scope": ( + "drive-all-accessible-workspace-native-v2" + if self.capture_native_sheets + else "drive-all-accessible-docs-native" + ), + }, + sort_keys=True, + ).encode() + ).hexdigest(), + parents={"file": (None,)}, + known_object_validation=("file",), + ) + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Only engine-attested full capture can authorize native changes.""" + return await DrivePages(self._get).prepare(previous, self.capture_cycle_configuration) + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """Restore the persisted starting boundary.""" + self._require_principal() + if cycle.configuration != self.capture_cycle_configuration: + raise ValueError("Drive cycle does not match the trusted capture configuration") + return DrivePages.initial(cycle) + + async def _capture_body(self, record: CaptureRecord, files: FileService) -> CaptureRecord: + return await capture_file_content( + record, + files=files, + get=self._get, + client=self.http_client, + auth=self.auth, + logger=self.logger, + capture_native_sheets=self.capture_native_sheets, + ) + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Commit one version-checked file without retaining a native response queue.""" + self._require_principal() + if scope != CompletedScope(record_type="file") or parent is not None: + raise ValueError("Drive requires its independent file scope") + + async def hydrate(record: CaptureRecord) -> CaptureRecord: + return await self._capture_body(record, files) + + return await DrivePages(self._get).page(continuation, hydrate) + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Known list omissions require an exact accessible-file read.""" + self._require_principal() + if record.identity.record_type != "file" or record.parent is not None: + raise ValueError("Drive omission must identify an independent file") + return await self._capture_body( + await DrivePages(self._get).current(record.identity.native_id), files + ) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Folders are native metadata, not proof of inherited access.""" + raise ValueError("Drive has no canonical child scopes") + + async def confirm_absent(self, record: SourceRecord) -> None: + """Every omitted file uses exact known-object refresh.""" + raise ValueError("Drive absence requires exact known-object refresh") + @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: GoogleDriveConfig, @@ -89,11 +218,15 @@ async def create( """Create a new Google Drive source instance.""" instance = cls(auth=auth, logger=logger, http_client=http_client) instance.include_patterns = config.include_patterns if config else [] + instance.expected_permission_id = config.expected_permission_id if config else None + instance.capture_native_sheets = config.capture_native_sheets if config else False instance.batch_size = 30 instance.batch_generation = True instance.max_queue_size = 200 instance.preserve_order = False instance.stop_on_error = False + if instance.expected_permission_id is not None: + await instance.validate() return instance @staticmethod @@ -102,11 +235,22 @@ def _parse_datetime(value: Optional[str]) -> Optional[datetime]: return _parse_drive_dt(value) async def validate(self) -> None: - """Validate credentials by pinging the shared drives list.""" - await self._get( - "https://www.googleapis.com/drive/v3/drives", - params={"pageSize": "1"}, + """Re-attest the native principal; failure clears any previous attestation.""" + self._verified_permission_id = None + raw = await self._get( + "https://www.googleapis.com/drive/v3/about", + params={"fields": "user(permissionId,me)"}, ) + try: + principal = DriveAbout.model_validate(raw).user + except ValidationError: + raise ValueError("Drive returned an invalid principal identity") from None + if not principal.me: + raise ValueError("Drive returned a principal that is not the authenticated user") + if self.expected_permission_id is not None: + if principal.permissionId != self.expected_permission_id: + raise ValueError("Drive principal identity does not match the trusted binding") + self._verified_permission_id = principal.permissionId @retry( stop=stop_after_attempt(5), @@ -123,15 +267,22 @@ async def _get(self, url: str, params: Optional[Dict] = None) -> Dict: Max 5 attempts with intelligent wait strategy. """ - token = await self.auth.get_token() - headers = {"Authorization": f"Bearer {token}"} + headers = await authorization_headers(self.auth) response = await self.http_client.get(url, headers=headers, params=params, timeout=30.0) if response.status_code == 401 and self.auth.supports_refresh: - new_token = await self.auth.force_refresh() - headers = {"Authorization": f"Bearer {new_token}"} + headers = await authorization_headers(self.auth, refresh=True) response = await self.http_client.get(url, headers=headers, params=params, timeout=30.0) + if ( + response.status_code == 400 + and url == "https://www.googleapis.com/drive/v3/files" + and params + and params.get("pageToken") + and rejected_listing_token(response.json()) + ): + raise InvalidScanContinuation("Drive rejected its saved inventory page token") + raise_for_status( response, source_short_name=self.short_name, diff --git a/backend/airweave/platform/sources/google_slides.py b/backend/airweave/platform/sources/google_slides.py index 1b6c8c155f..86feff4b26 100644 --- a/backend/airweave/platform/sources/google_slides.py +++ b/backend/airweave/platform/sources/google_slides.py @@ -38,12 +38,12 @@ GoogleSlidesPresentationEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/http_helpers.py b/backend/airweave/platform/sources/http_helpers.py index 593940f351..02edda11c6 100644 --- a/backend/airweave/platform/sources/http_helpers.py +++ b/backend/airweave/platform/sources/http_helpers.py @@ -27,6 +27,7 @@ SourceEntityForbiddenError, SourceEntityNotFoundError, SourceError, + SourceGoneError, SourceRateLimitError, SourceServerError, ) @@ -107,6 +108,8 @@ def _handle_redirect( def _handle_fallback( resp: httpx.Response, status: int, detail: str, ctx: str, sn: str, _tpk: AuthProviderKind ) -> None: + if status == 410: + raise SourceGoneError(f"Resource or continuation expired (410){ctx}", source_short_name=sn) if status >= 500: raise SourceServerError( f"Server error ({status}){ctx}: {detail}", @@ -157,7 +160,7 @@ def translate_httpx_error( def _extract_detail(response: httpx.Response, max_length: int = 200) -> str: - """Pull a human-readable snippet from the response body.""" + """Use buffered detail only; status translation must never consume a stream.""" try: data = response.json() for key in ("message", "error_description", "error", "detail", "errors"): @@ -165,6 +168,8 @@ def _extract_detail(response: httpx.Response, max_length: int = 200) -> str: if val: return str(val)[:max_length] return str(data)[:max_length] + except httpx.ResponseNotRead: + return f"(unread response body, status {response.status_code})" except Exception: text = response.text[:max_length] if response.text else "" return text or f"(no body, status {response.status_code})" diff --git a/backend/airweave/platform/sources/hubspot.py b/backend/airweave/platform/sources/hubspot.py index c56551a82c..430e5999ed 100644 --- a/backend/airweave/platform/sources/hubspot.py +++ b/backend/airweave/platform/sources/hubspot.py @@ -26,12 +26,12 @@ parse_hubspot_datetime, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/intercom.py b/backend/airweave/platform/sources/intercom.py index a8120e1062..f52b017286 100644 --- a/backend/airweave/platform/sources/intercom.py +++ b/backend/airweave/platform/sources/intercom.py @@ -31,12 +31,12 @@ _unwrap_list, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType API_BASE = "https://api.intercom.io" diff --git a/backend/airweave/platform/sources/jira.py b/backend/airweave/platform/sources/jira.py index 660be3caa6..e52a3aabea 100644 --- a/backend/airweave/platform/sources/jira.py +++ b/backend/airweave/platform/sources/jira.py @@ -33,12 +33,12 @@ ZephyrTestPlanEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType ATLASSIAN_ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" diff --git a/backend/airweave/platform/sources/linear.py b/backend/airweave/platform/sources/linear.py index bd7fe76f98..5879cd04ce 100644 --- a/backend/airweave/platform/sources/linear.py +++ b/backend/airweave/platform/sources/linear.py @@ -1,72 +1,111 @@ -"""Linear source implementation for Airweave platform.""" +"""Selected-team Linear originals, with engine-owned durable full-scope pagination.""" from __future__ import annotations -import mimetypes -import os -import re +import hashlib +import json import time +from collections.abc import AsyncGenerator +from contextlib import aclosing from datetime import datetime, timezone -from typing import AsyncGenerator, Dict, List, Optional, Union -from uuid import uuid4 +from functools import partial +from pathlib import Path +from typing import Literal +from urllib.parse import urlsplit +from uuid import UUID import httpx -from tenacity import retry, stop_after_attempt +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, StrictBool +from tenacity import retry, retry_if_exception_type, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import ( - SourceAuthError, - SourceError, - SourceRateLimitError, +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import CapturePage, ScopeAccessLost +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceError, SourceRateLimitError +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, ) -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol -from airweave.domains.storage import FileSkippedException from airweave.domains.storage.file_service import FileService +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.config import LinearConfig -from airweave.platform.cursors.linear import LinearCursor from airweave.platform.decorators import source -from airweave.platform.entities._base import BaseEntity, Breadcrumb -from airweave.platform.entities.linear import ( - LinearAttachmentEntity, - LinearCommentEntity, - LinearIssueEntity, - LinearProjectEntity, - LinearTeamEntity, - LinearUserEntity, -) +from airweave.platform.entities._base import BaseEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType -_GRAPHQL_URL = "https://api.linear.app/graphql" +_URL = "https://api.linear.app/graphql" +_QUERIES = Path(__file__).with_name("linear_queries") +_QUERY_NAMES = ("identity", "teams", "issues", "comments", "attachments", "issue-membership") +QUERIES = {name: (_QUERIES / f"{name}.graphql").read_text() for name in _QUERY_NAMES} +FIELD_SET_VERSION = 3 -def _is_graphql_rate_limited(body: Dict) -> bool: - """Check if a Linear GraphQL response contains a RATELIMITED error.""" - for err in body.get("errors", []): - if err.get("extensions", {}).get("code") == "RATELIMITED": - return True - return False +class _Model(BaseModel): + model_config = ConfigDict(extra="ignore") -def _parse_reset_header(response: httpx.Response, default: float = 60.0) -> float: - """Compute seconds-until-reset from ``X-RateLimit-Requests-Reset`` (epoch ms).""" - raw = response.headers.get("X-RateLimit-Requests-Reset") - if raw: - try: - reset_epoch_s = int(raw) / 1000.0 - return max(1.0, reset_epoch_s - time.time()) - except (ValueError, TypeError): - pass - return default +class _Identity(_Model): + id: UUID + + +class _PageInfo(_Model): + hasNextPage: StrictBool + endCursor: str | None + + +class _Connection(_Model): + nodes: list[dict[str, JsonValue]] = Field(max_length=50) + pageInfo: _PageInfo + + +class _Issue(_Identity): + team: _Identity + + +class _Child(_Identity): + issue: _Identity + + +class _Dates(_Model): + createdAt: AwareDatetime + updatedAt: AwareDatetime + + +class _ErrorExtensions(_Model): + code: str | None = None + + +class _GraphError(_Model): + extensions: _ErrorExtensions = Field(default_factory=_ErrorExtensions) + + +class _Envelope(_Model): + data: dict[str, JsonValue] | None = None + errors: list[_GraphError] = Field(default_factory=list) + + +class _Progress(BaseModel): + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + after: str | None = Field(default=None, min_length=1) @source( @@ -81,534 +120,292 @@ def _parse_reset_header(response: httpx.Response, default: float = 60.0) -> floa auth_config_class=None, config_class=LinearConfig, labels=["Project Management"], - supports_continuous=True, - cursor_class=LinearCursor, + supports_continuous=False, rate_limit_level=RateLimitLevel.ORG, ) class LinearSource(BaseSource): - """Linear source connector — syncs teams, projects, users, issues, comments, attachments.""" + """Capture selected issue originals and independently paginated child originals.""" + + canonical_record_types = ("issue", "comment", "attachment") + canonical_container_parents = {"comment": "issue", "attachment": "issue"} + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Expose canonical capability on the class, with instance-specific scope binding.""" + return self._cycle_configuration @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: LinearConfig, ) -> LinearSource: - """Create a new Linear source instance.""" - return cls(auth=auth, logger=logger, http_client=http_client) - - # ------------------------------------------------------------------ - # HTTP - # ------------------------------------------------------------------ + """Attest workspace and selected-team access before constructing a capture cycle.""" + instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.config = config + instance.team_ids = frozenset(str(value) for value in config.team_ids) + fingerprint = { + "workspace": str(config.workspace_id), + "teams": sorted(instance.team_ids), + "field_set": FIELD_SET_VERSION, + "include_archived": True, + "queries": QUERIES, + "kinds": cls.canonical_record_types, + } + instance._cycle_configuration = CycleConfiguration.from_source( + fingerprint=hashlib.sha256( + json.dumps(fingerprint, sort_keys=True).encode() + ).hexdigest(), + record_types=cls.canonical_record_types, + container_parents=cls.canonical_container_parents, + ) + await instance.validate() + return instance @retry( stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), reraise=True, ) - async def _post(self, query: str) -> Dict: - """Authenticated GraphQL POST with 401 refresh and raise_for_status. - - Linear returns rate-limit errors as HTTP 400 with a GraphQL - ``RATELIMITED`` error code (not HTTP 429). We detect this before - ``raise_for_status`` so the tenacity retry fires correctly. - """ - token = await self.auth.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - } - response = await self.http_client.post(_GRAPHQL_URL, headers=headers, json={"query": query}) - + async def _query(self, name: str, variables: dict[str, JsonValue]) -> dict[str, JsonValue]: + headers = await authorization_headers(self.auth) + response = await self.http_client.post( + _URL, headers=headers, json={"query": QUERIES[name], "variables": variables} + ) if response.status_code == 401 and self.auth.supports_refresh: - new_token = await self.auth.force_refresh() - headers["Authorization"] = f"Bearer {new_token}" + headers = await authorization_headers(self.auth, refresh=True) response = await self.http_client.post( - _GRAPHQL_URL, headers=headers, json={"query": query} - ) - - body = response.json() - - if response.status_code == 400 and _is_graphql_rate_limited(body): - retry_after = _parse_reset_header(response) - raise SourceRateLimitError( - retry_after=retry_after, - source_short_name=self.short_name, - message=f"Linear RATELIMITED (400). Retry after {retry_after:.0f}s", + _URL, headers=headers, json={"query": QUERIES[name], "variables": variables} ) - + # Inspect only the documented GraphQL rate-limit response before HTTP translation. + if response.status_code in {200, 400}: + envelope = _Envelope.model_validate(response.json()) + if any(error.extensions.code == "RATELIMITED" for error in envelope.errors): + raw_reset = response.headers.get("X-RateLimit-Requests-Reset") + try: + delay = max(1.0, float(raw_reset) / 1000 - time.time()) if raw_reset else 60.0 + except ValueError: + delay = 60.0 + raise SourceRateLimitError(source_short_name="linear", retry_after=delay) raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + response, source_short_name="linear", token_provider_kind=self.auth.provider_kind ) - - return body - - # ------------------------------------------------------------------ - # Pagination - # ------------------------------------------------------------------ - - async def _paginated_query( - self, - query_template: str, - process_node_func, - page_size: int = 50, - entity_type: str = "items", - ) -> AsyncGenerator: - """Paginate through Linear GraphQL, yielding entities from process_node_func. - - SourceAuthError propagates immediately (abort sync). - Other errors propagate to the caller (typically _generate_entities_safe). - """ - has_next_page = True - after_cursor = None - items_processed = 0 - - while has_next_page: - pagination = f"first: {page_size}" - if after_cursor: - pagination += f', after: "{after_cursor}"' - - query = query_template.format(pagination=pagination) - response = await self._post(query) - - data = response.get("data", {}) - collection_key = next(iter(data.keys()), None) - - if not collection_key: - self.logger.warning(f"Unexpected response structure: {response}") - break - - collection_data = data[collection_key] - nodes = collection_data.get("nodes", []) - - batch_count = len(nodes) - items_processed += batch_count - self.logger.debug( - f"Processing batch of {batch_count} {entity_type} (total: {items_processed})" + envelope = _Envelope.model_validate(response.json()) + if envelope.errors or envelope.data is None: + # Never log potentially sensitive provider error messages or accept partial data. + raise SourceError( + "Linear GraphQL did not return complete data", source_short_name="linear" ) + return envelope.data - for node in nodes: - async for entity in process_node_func(node): - if entity: - yield entity - - page_info = collection_data.get("pageInfo", {}) - has_next_page = page_info.get("hasNextPage", False) - after_cursor = page_info.get("endCursor") - - if not nodes or not has_next_page: + async def validate(self) -> None: + """Verify native workspace identity and current discovery of configured teams.""" + data = await self._query("identity", {}) + workspace = _Identity.model_validate(data.get("organization")) + self.viewer_id = _Identity.model_validate(data.get("viewer")).id + if workspace.id != self.config.workspace_id: + raise ValueError("Linear workspace does not match configured workspace") + found: set[str] = set() + after = None + seen: set[str] = set() + while True: + data = await self._query("teams", {"first": 50, "after": after}) + page = _Connection.model_validate(data.get("teams")) + found.update(str(_Identity.model_validate(node).id) for node in page.nodes) + after = self._following(page, after) + if after is None: break + if after in seen: + raise ValueError("Linear team discovery repeated a cursor") + seen.add(after) + if not self.team_ids <= found: + raise ValueError("One or more selected Linear teams are unavailable") + + @staticmethod + def _following(page: _Connection, current: str | None) -> str | None: + if not page.pageInfo.hasNextPage: + return None + following = page.pageInfo.endCursor + if not following or following == current: + raise ValueError("Linear returned a missing or non-advancing continuation") + return following + + def _check_issue(self, payload: JsonValue, expected: str | None = None) -> _Issue: + issue = _Issue.model_validate(payload) + if expected is not None and str(issue.id) != expected: + raise ValueError("Linear returned the wrong issue identity") + if str(issue.team.id) not in self.team_ids: + raise ScopeAccessLost( + "Linear issue moved outside selected teams", removal_reason="scope_removed" + ) + return issue - # ------------------------------------------------------------------ - # Entity generators - # ------------------------------------------------------------------ - - async def _generate_team_entities( - self, since: Optional[str] = None - ) -> AsyncGenerator[LinearTeamEntity, None]: - """Generate entities for all teams in the workspace.""" - filter_clause = f'filter: {{{{ updatedAt: {{{{ gte: "{since}" }}}} }}}}, ' if since else "" - query_template = ( - """ - {{ - teams(""" - + filter_clause - + """{pagination}) {{ - nodes {{ - id - name - key - description - color - icon - private - timezone - createdAt - updatedAt - parent {{ - id - name - }} - issueCount - }} - pageInfo {{ - hasNextPage - endCursor - }} - }} - }} - """ - ) - - async def process_team(team): - self.logger.debug(f"Processing team: {team.get('name')} ({team.get('key')})") - yield LinearTeamEntity.from_api(team) - - async for entity in self._paginated_query( - query_template, process_team, entity_type="teams" - ): - yield entity - - async def _generate_project_entities( - self, since: Optional[str] = None - ) -> AsyncGenerator[LinearProjectEntity, None]: - """Generate entities for all projects in the workspace.""" - filter_clause = f'filter: {{{{ updatedAt: {{{{ gte: "{since}" }}}} }}}}, ' if since else "" - query_template = ( - """ - {{ - projects(""" - + filter_clause - + """{pagination}) {{ - nodes {{ - id - name - slugId - description - priority - startDate - targetDate - state - createdAt - updatedAt - completedAt - startedAt - progress - teams {{ - nodes {{ - id - name - }} - }} - lead {{ - name - }} - }} - pageInfo {{ - hasNextPage - endCursor - }} - }} - }} - """ - ) - - async def process_project(project): - self.logger.debug(f"Processing project: {project.get('name')}") - yield LinearProjectEntity.from_api(project) - - async for entity in self._paginated_query( - query_template, process_project, entity_type="projects" - ): - yield entity - - async def _generate_user_entities( - self, since: Optional[str] = None - ) -> AsyncGenerator[LinearUserEntity, None]: - """Generate entities for all users in the workspace.""" - filter_clause = f'filter: {{{{ updatedAt: {{{{ gte: "{since}" }}}} }}}}, ' if since else "" - query_template = ( - """ - {{ - users(""" - + filter_clause - + """{pagination}) {{ - nodes {{ - id - name - displayName - email - avatarUrl - description - timezone - active - admin - guest - lastSeen - statusEmoji - statusLabel - statusUntilAt - createdIssueCount - createdAt - updatedAt - teams {{ - nodes {{ - id - name - key - }} - }} - }} - pageInfo {{ - hasNextPage - endCursor - }} - }} - }} - """ + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Keep the established native container identity for this flat provider.""" + return CompletedScope( + record_type=record_type, container_id=parent.identity.native_id, parent=parent.identity ) - async def process_user(user): - self.logger.debug(f"Processing user: {user.get('name')} ({user.get('displayName')})") - yield LinearUserEntity.from_api(user) - - async for entity in self._paginated_query( - query_template, process_user, entity_type="users" - ): - yield entity - - # ------------------------------------------------------------------ - # Issues, comments, and attachments - # ------------------------------------------------------------------ - - async def _process_issue_comments( + async def capture_page( self, - comments: List[Dict], - issue: LinearIssueEntity, - issue_breadcrumbs: List[Breadcrumb], - ) -> AsyncGenerator[LinearCommentEntity, None]: - """Yield comment entities for an issue.""" - for comment in comments: - if not comment.get("body", "").strip(): - continue - yield LinearCommentEntity.from_api( - comment, - issue_id=issue.issue_id, - issue_identifier=issue.identifier, - breadcrumbs=issue_breadcrumbs, - team_id=issue.team_id, - team_name=issue.team_name, - project_id=issue.project_id, - project_name=issue.project_name, - ) + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Fetch exactly one whole-scope page; engine commits it and its continuation.""" + progress = _Progress.model_validate(continuation.value) + variables: dict[str, JsonValue] = {"first": 50, "after": progress.after} + if scope.record_type == "issue" and scope.container_id is None: + variables["teamIds"] = sorted(self.team_ids) + data = await self._query("issues", variables) + page = _Connection.model_validate(data.get("issues")) + for node in page.nodes: + issue = _Issue.model_validate(node) + if str(issue.team.id) not in self.team_ids: + raise ValueError("Linear root page contains an unselected team") + elif scope.record_type in {"comment", "attachment"} and scope.container_id: + UUID(scope.container_id) + variables["issueId"] = scope.container_id + name = "comments" if scope.record_type == "comment" else "attachments" + data = await self._query(name, variables) + issue_payload = self._single_issue(data, scope.container_id) + if issue_payload is None: + raise ScopeAccessLost( + "Linear issue is unavailable in the current accessible scope", + removal_reason="scope_removed", + ) + self._check_issue(issue_payload, scope.container_id) + issue_data = _ChildConnection.model_validate(issue_payload) + page = issue_data.comments if name == "comments" else issue_data.attachments + if page is None: + raise ValueError("Linear omitted the requested child connection") + for node in page.nodes: + if str(_Child.model_validate(node).issue.id) != scope.container_id: + raise ValueError("Linear child belongs to a different issue") + else: + raise ValueError("Unsupported Linear scope") + following = self._following(page, progress.after) + return CapturePage( + records=tuple([await self._retain(scope, node, files) for node in page.nodes]), + continuation=ScanContinuation(value=_Progress(after=following).model_dump()), + final=following is None, + ) - async def _download_description_attachment( + async def _retain( self, - entity: LinearAttachmentEntity, + scope: CompletedScope, + payload: dict[str, JsonValue], files: FileService, - ) -> LinearAttachmentEntity | None: - """Download an inline attachment via FileService. Returns None on expected skips. - - 401 after refresh propagates (token is dead → abort sync). - Infrastructure failures (IOError, OSError) propagate. - Other HTTP errors skip the file. - """ - try: - await files.download_from_url( - entity=entity, - client=self.http_client, - auth=self.auth, - logger=self.logger, + ) -> CaptureRecord: + record = self._capture(scope, payload) + if scope.record_type != "attachment": + return record + url = payload.get("url") + if not isinstance(url, str) or not self._is_upload(url): + return record + headers = await authorization_headers(self.auth) + async with self.http_client.stream( + "GET", + url, + headers=headers, + follow_redirects=False, + timeout=httpx.Timeout(180.0, read=540.0), + ) as response: + # Redirects are errors, never permission to forward auth to another destination. + raise_for_status( + response, source_short_name="linear", token_provider_kind=self.auth.provider_kind ) - if not entity.local_path: - self.logger.warning(f"Download produced no local path for {entity.name}") - return None - return entity - except FileSkippedException as e: - self.logger.debug(f"Skipping attachment {entity.attachment_id}: {e.reason}") - return None - except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise - self.logger.warning( - f"HTTP {e.response.status_code} downloading attachment {entity.attachment_id}: {e}" + length = response.headers.get("Content-Length") + if length is not None and int(length) > MAX_FILE_SIZE_BYTES: + return record + content = bytearray() + async with aclosing(response.aiter_bytes()) as chunks: + async for chunk in chunks: + if len(content) + len(chunk) > MAX_FILE_SIZE_BYTES: + return record + content.extend(chunk) + media_type = ( + response.headers.get("Content-Type", "application/octet-stream") + .split(";", 1)[0] + .strip() ) - return None - - async def _generate_attachment_entities_from_description( - self, - issue_id: str, - issue_identifier: str, - issue_description: str, - breadcrumbs: List[Breadcrumb], - files: FileService, - ) -> AsyncGenerator[LinearAttachmentEntity, None]: - """Extract and download attachments from markdown links in issue descriptions.""" - if not issue_description: - return - - markdown_link_pattern = r"\[([^\]]+)\]\(([^)]+)\)" - matches = re.findall(markdown_link_pattern, issue_description) - - self.logger.debug( - f"Found {len(matches)} potential attachments in description " - f"for issue {issue_identifier}" + ref = await files.store_canonical_blob(bytes(content), media_type=media_type) + return record.model_copy( + update={ + "blobs": (ref.model_copy(update={"source_path": "/url"}),), + "completeness": "complete", + } ) - for file_name, url in matches: - if "uploads.linear.app" not in url: - continue - - self.logger.debug(f"Processing attachment from description: {file_name} - URL: {url}") - - attachment_id = str(uuid4()) - mime_type = mimetypes.guess_type(file_name)[0] - if mime_type and "/" in mime_type: - file_type = mime_type.split("/")[0] - else: - ext = os.path.splitext(file_name)[1].lower().lstrip(".") - file_type = ext if ext else "file" - - attachment_entity = LinearAttachmentEntity( - entity_id=attachment_id, - breadcrumbs=breadcrumbs.copy(), - name=file_name, - created_at=None, - updated_at=None, - url=url, - size=0, - file_type=file_type, - mime_type=mime_type or "application/octet-stream", - local_path=None, - attachment_id=attachment_id, - issue_id=issue_id, - issue_identifier=issue_identifier, - title=file_name, - subtitle="Extracted from issue description", - source={"type": "description_link"}, - web_url_value=url, + @staticmethod + def _is_upload(url: str) -> bool: + try: + parsed = urlsplit(url) + return ( + parsed.scheme == "https" + and parsed.hostname == "uploads.linear.app" + and parsed.port in {None, 443} + and parsed.username is None + and parsed.password is None + and bool(parsed.path.strip("/")) + and not parsed.fragment ) - - downloaded = await self._download_description_attachment(attachment_entity, files) - if downloaded: - yield downloaded - - async def _generate_issue_entities( - self, - since: Optional[str] = None, - files: FileService | None = None, - ) -> AsyncGenerator[ - Union[LinearIssueEntity, LinearCommentEntity, LinearAttachmentEntity], None - ]: - """Generate entities for all issues, their comments, and their attachments.""" - updated_filter = f', updatedAt: {{{{ gte: "{since}" }}}}' if since else "" - query_template = ( - """ - {{ - issues(filter: {{ archivedAt: {{ null: true }}""" - + updated_filter - + """ }}, {pagination}) {{ - nodes {{ - id - identifier - title - description - priority - completedAt - createdAt - updatedAt - dueDate - archivedAt - state {{ - name - }} - team {{ - id - name - }} - project {{ - id - name - }} - assignee {{ - name - }} - comments {{ - nodes {{ - id - body - createdAt - updatedAt - user {{ - id - name - }} - }} - }} - }} - pageInfo {{ - hasNextPage - endCursor - }} - }} - }} - """ + except ValueError: + return False + + @staticmethod + def _capture(scope: CompletedScope, payload: dict[str, JsonValue]) -> CaptureRecord: + identity = _Identity.model_validate(payload) + dates = _Dates.model_validate(payload) + text = payload.get("description") if scope.record_type == "issue" else payload.get("body") + # Retain links and native text; no claim that embedded or attachment bytes are stored. + partial_content = scope.record_type == "attachment" or ( + isinstance(text, str) and "uploads.linear.app" in text + ) + return CaptureRecord( + identity=RecordIdentity( + record_type=scope.record_type, + native_id=str(identity.id), + container_id=scope.container_id, + ), + payload=payload, + payload_schema_version=FIELD_SET_VERSION, + completeness="partial" if partial_content else "complete", + source_created_at=dates.createdAt, + source_updated_at=dates.updatedAt, + observed_at=datetime.now(timezone.utc), ) - async def process_issue(data): - if data.get("archivedAt"): - self.logger.warning( - f"Archived issue {data.get('identifier')} passed GraphQL filter — skipping" - ) - return - - self.logger.debug(f"Processing issue: {data.get('identifier')} — '{data.get('title')}'") - - issue = LinearIssueEntity.from_api(data) - yield issue - - issue_breadcrumbs = issue.breadcrumbs + [ - Breadcrumb( - entity_id=issue.issue_id, - name=issue.title, - entity_type=LinearIssueEntity.__name__, - ) - ] - - comments = data.get("comments", {}).get("nodes", []) - self.logger.debug(f"Processing {len(comments)} comments for issue {issue.identifier}") - async for comment in self._process_issue_comments(comments, issue, issue_breadcrumbs): - yield comment - - if issue.description and files: - async for attachment in self._generate_attachment_entities_from_description( - issue.issue_id, - issue.identifier, - issue.description, - issue_breadcrumbs, - files, - ): - yield attachment - - async for entity in self._paginated_query( - query_template, process_issue, entity_type="issues" - ): - yield entity - - # ------------------------------------------------------------------ - # Error isolation - # ------------------------------------------------------------------ - - async def _generate_entities_safe( - self, - generator: AsyncGenerator, - entity_type: str, - ) -> AsyncGenerator: - """Yield entities with error isolation per entity type. - - SourceAuthError propagates (abort sync — credentials dead). - Other exceptions are logged and stop that entity type only. - """ - try: - self.logger.debug(f"Starting {entity_type} entity generation") - async for entity in generator: - yield entity - except SourceAuthError: - raise - except SourceError as exc: - self.logger.warning(f"Failed to generate {entity_type} entities: {exc}") - except Exception as exc: - self.logger.warning(f"Unexpected error generating {entity_type} entities: {exc}") - - # ------------------------------------------------------------------ - # Main entry point - # ------------------------------------------------------------------ + @staticmethod + def _single_issue(data: dict[str, JsonValue], native_id: str) -> dict[str, JsonValue] | None: + """Interpret only a successful exhausted exact-ID connection, never failed lookups.""" + page = _Connection.model_validate(data.get("issues")) + if page.pageInfo.hasNextPage or len(page.nodes) > 1: + raise ValueError("Linear exact-ID membership response is not complete and unique") + if not page.nodes: + return None + node = page.nodes[0] + if str(_Identity.model_validate(node).id) != native_id: + raise ValueError("Linear returned the wrong issue identity") + return node + + async def confirm_absent(self, record: SourceRecord) -> None: + """Fail closed on ambiguous absence; readable out-of-scope roots may be removed.""" + native_id = record.identity.native_id + UUID(native_id) + data = await self._query("issue-membership", {"issueId": native_id}) + payload = self._single_issue(data, native_id) + if payload is None: + return + issue = _Issue.model_validate(payload) + if str(issue.team.id) in self.team_ids: + raise ValueError("Linear omitted an issue still readable in the selected scope") async def generate_entities( self, @@ -617,48 +414,11 @@ async def generate_entities( files: FileService | None = None, node_selections: list[NodeSelectionData] | None = None, ) -> AsyncGenerator[BaseEntity, None]: - """Generate all entities from Linear. - - On first sync (no cursor), all entities are fetched. On subsequent syncs, - only entities updated since the last sync are fetched. - """ - cursor_data = cursor.data if cursor else {} - last_synced_at = cursor_data.get("last_synced_at") or None - sync_start = datetime.now(tz=timezone.utc).isoformat() - - if last_synced_at: - self.logger.info(f"Incremental sync from {last_synced_at}") - else: - self.logger.info("Full sync (first run)") + """Legacy crawling is replaced by canonical page capture and offline projection.""" + raise NotImplementedError("Linear requires canonical page capture") + yield # pragma: no cover - async for entity in self._generate_entities_safe( - self._generate_team_entities(since=last_synced_at), "team" - ): - yield entity - async for entity in self._generate_entities_safe( - self._generate_project_entities(since=last_synced_at), "project" - ): - yield entity - - async for entity in self._generate_entities_safe( - self._generate_user_entities(since=last_synced_at), "user" - ): - yield entity - - async for entity in self._generate_entities_safe( - self._generate_issue_entities(since=last_synced_at, files=files), - "issue, comment, and attachment", - ): - yield entity - - if cursor: - cursor.update(last_synced_at=sync_start) - - # ------------------------------------------------------------------ - # Validation - # ------------------------------------------------------------------ - - async def validate(self) -> None: - """Verify Linear OAuth2 token by POSTing a minimal GraphQL query.""" - await self._post("query { viewer { id } }") +class _ChildConnection(_Model): + comments: _Connection | None = None + attachments: _Connection | None = None diff --git a/backend/airweave/platform/sources/linear_queries/attachments.graphql b/backend/airweave/platform/sources/linear_queries/attachments.graphql new file mode 100644 index 0000000000..034702fa0e --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/attachments.graphql @@ -0,0 +1,12 @@ +query OwnedLinearIssueAttachments($issueId: ID!, $first: Int!, $after: String) { + issues(filter: {id: {eq: $issueId}}, includeArchived: true, first: 2) { + nodes { + id team { id } + attachments(first: $first, after: $after, includeArchived: true, orderBy: createdAt) { + nodes { id title url metadata createdAt updatedAt archivedAt issue { id identifier title url team { id name } project { id name } } } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } +} diff --git a/backend/airweave/platform/sources/linear_queries/comments.graphql b/backend/airweave/platform/sources/linear_queries/comments.graphql new file mode 100644 index 0000000000..ef6ac1b97c --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/comments.graphql @@ -0,0 +1,17 @@ +query OwnedLinearIssueComments($issueId: ID!, $first: Int!, $after: String) { + issues(filter: {id: {eq: $issueId}}, includeArchived: true, first: 2) { + nodes { + id team { id } + comments(first: $first, after: $after, includeArchived: true, orderBy: createdAt) { + nodes { + id body url createdAt updatedAt archivedAt resolvedAt + user { id name updatedAt } + parent { id } + issue { id identifier title url team { id name } project { id name } } + } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } +} diff --git a/backend/airweave/platform/sources/linear_queries/identity.graphql b/backend/airweave/platform/sources/linear_queries/identity.graphql new file mode 100644 index 0000000000..a72d843d35 --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/identity.graphql @@ -0,0 +1,4 @@ +query OwnedLinearIdentity { + viewer { id name } + organization { id name } +} diff --git a/backend/airweave/platform/sources/linear_queries/issue-membership.graphql b/backend/airweave/platform/sources/linear_queries/issue-membership.graphql new file mode 100644 index 0000000000..69418b8c2f --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/issue-membership.graphql @@ -0,0 +1,6 @@ +query OwnedLinearIssueMembership($issueId: ID!) { + issues(filter: {id: {eq: $issueId}}, includeArchived: true, first: 2) { + nodes { id team { id } archivedAt trashed } + pageInfo { hasNextPage endCursor } + } +} diff --git a/backend/airweave/platform/sources/linear_queries/issues.graphql b/backend/airweave/platform/sources/linear_queries/issues.graphql new file mode 100644 index 0000000000..f4266be157 --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/issues.graphql @@ -0,0 +1,15 @@ +query OwnedLinearIssues($teamIds: [ID!]!, $first: Int!, $after: String) { + issues(filter: {team: {id: {in: $teamIds}}}, first: $first, after: $after, + includeArchived: true, orderBy: createdAt) { + nodes { + id identifier title description url createdAt updatedAt archivedAt trashed + priority dueDate + team { id name key visibility archivedAt updatedAt } + project { id name updatedAt archivedAt } + assignee { id name updatedAt } + creator { id name updatedAt } + state { id name type updatedAt } + } + pageInfo { hasNextPage endCursor } + } +} diff --git a/backend/airweave/platform/sources/linear_queries/teams.graphql b/backend/airweave/platform/sources/linear_queries/teams.graphql new file mode 100644 index 0000000000..82d77c9ef3 --- /dev/null +++ b/backend/airweave/platform/sources/linear_queries/teams.graphql @@ -0,0 +1,6 @@ +query OwnedLinearTeamChoices($first: Int!, $after: String) { + teams(first: $first, after: $after, includeArchived: true, orderBy: createdAt) { + nodes { id name key visibility archivedAt updatedAt } + pageInfo { hasNextPage endCursor } + } +} diff --git a/backend/airweave/platform/sources/monday.py b/backend/airweave/platform/sources/monday.py index a9e5c2e75b..59d791c7a9 100644 --- a/backend/airweave/platform/sources/monday.py +++ b/backend/airweave/platform/sources/monday.py @@ -30,12 +30,12 @@ MondayUpdateEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/notion.py b/backend/airweave/platform/sources/notion.py index 57c2856713..040a4924da 100644 --- a/backend/airweave/platform/sources/notion.py +++ b/backend/airweave/platform/sources/notion.py @@ -1,45 +1,201 @@ -"""Notion connector for syncing content from Notion workspaces to Airweave. +"""Native Notion originals; incomplete discovery never establishes absence. -This module provides a comprehensive source implementation for extracting databases and pages -with full aggregated content from Notion, handling API rate limits, and converting API -responses to entity objects. +Page/database/data-source topology entries describe enumeration scopes, not grants. +These originals are independently retrieved roots. Blocks alone inherit access from +native owners. Properties, comments and file bytes are not complete in this slice. """ from __future__ import annotations -from typing import Any, AsyncGenerator, Dict, List, Optional, Set, Tuple +import hashlib +import json +from collections.abc import AsyncGenerator, Sequence +from datetime import datetime, timezone +from functools import partial +from typing import Annotated, Literal +from uuid import UUID -import httpx -from tenacity import retry, stop_after_attempt +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, StrictBool +from tenacity import retry, retry_if_exception_type, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import SourceAuthError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol -from airweave.domains.storage import FileSkippedException +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + InvalidScanContinuation, + ScopeAccessLost, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceAuthError, SourceError +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage.file_service import FileService +from airweave.domains.storage.limits import MAX_FILE_SIZE_BYTES from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.config import NotionConfig from airweave.platform.decorators import source -from airweave.platform.entities._base import BaseEntity, Breadcrumb -from airweave.platform.entities.notion import ( - NotionDatabaseEntity, - NotionFileEntity, - NotionPageEntity, - NotionPropertyEntity, - _extract_page_title, - _extract_rich_text_plain, -) +from airweave.platform.entities._base import BaseEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType +API_VERSION = "2026-03-11" +FIELD_SET_VERSION = 2 +_ROOTS = ("page", "database", "data_source") +_ENDPOINTS = { + "page": "pages", + "database": "databases", + "data_source": "data_sources", + "block": "blocks", +} +_PAGE_SIZE = 25 + + +class _Native(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class _Object(_Native): + id: UUID + object: Literal["page", "database", "data_source", "block"] + created_time: AwareDatetime + last_edited_time: AwareDatetime + in_trash: StrictBool = False + + +class _Parent(_Native): + type: str + page_id: UUID | None = None + block_id: UUID | None = None + database_id: UUID | None = None + data_source_id: UUID | None = None + + +class _Located(_Object): + parent: _Parent + + +class _Block(_Located): + type: str + has_children: StrictBool + synced_block: dict[str, JsonValue] | None = None + + +class _Database(_Object): + data_sources: list[dict[str, JsonValue]] = Field(max_length=1000) + + +class _Id(_Native): + id: UUID + + +class _BotWorkspace(_Native): + workspace_id: UUID + + +class _Principal(_Id): + object: Literal["user"] + type: Literal["bot"] + bot: _BotWorkspace + + +class _Status(_Native): + type: Literal["complete", "incomplete"] + incomplete_reason: str | None = None + + +class _List(_Native): + object: Literal["list"] + results: list[dict[str, JsonValue]] = Field(max_length=_PAGE_SIZE) + has_more: StrictBool + next_cursor: str | None + request_status: _Status | None = None + + +class _Property(_Native): + # Native IDs already contain percent encoding. Accept one URL path segment, + # including valid escapes, and never quote it a second time. + id: str = Field(pattern=r"^(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+$") + type: str + + +class _PageProperties(_Object): + properties: dict[str, dict[str, JsonValue]] + + +class _Calculation(_Native): + type: str + + +class _PropertyMetadata(_Property): + rollup: _Calculation | None = None + formula: _Calculation | None = None + + +class _PropertyItem(_PropertyMetadata): + object: Literal["property_item"] + + +class _PropertyList(_List): + type: Literal["property_item"] + property_item: _PropertyMetadata + + +class _PropertyArchiveHeader(BaseModel): + model_config = ConfigDict(extra="forbid") + format_version: Literal[1] = 1 + page_id: UUID + property_id: str + notion_version: Literal["2026-03-11"] = API_VERSION + page_last_edited_time: AwareDatetime + + +class _PropertyManifest(_PropertyArchiveHeader): + name: str + property: dict[str, JsonValue] + response_count: int = Field(ge=1) + value_status: Literal["available", "unsupported"] + + +class _PropertyProgress(BaseModel): + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + index: int = Field(default=0, ge=0) + inventory: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") + + +class _Error(_Native): + code: str + + +class _Progress(BaseModel): + model_config = ConfigDict(extra="forbid") + version: Literal[1] = 1 + cursor: str | None = Field(default=None, min_length=1, max_length=4096) + offset: int = Field(default=0, ge=0, le=1000) + window_start: AwareDatetime | None = None + last_created: AwareDatetime | None = None + capped: bool = False + cursor_hashes: tuple[Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")], ...] = Field( + default=(), max_length=512 + ) + @source( name="Notion", @@ -57,1223 +213,580 @@ rate_limit_level=RateLimitLevel.CONNECTION, ) class NotionSource(BaseSource): - """Notion source connector integrates with the Notion API to extract and synchronize content. - - Connects to your Notion workspace. - - It provides comprehensive access to databases, pages, and content with advanced content - aggregation, lazy loading, and file processing capabilities for optimal performance. - """ - - TIMEOUT_SECONDS = 60.0 - - class NotionAccessError(Exception): - """Non-retryable access/shape error from Notion. - - Raised for permission or structural cases that won't succeed on retry - (e.g., 403/404, or specific 400 validation for linked views). - """ - - def __init__(self, status: int, message: str, url: str): - """Initialize with HTTP status, message, and request URL.""" - super().__init__(f"HTTP {status} - {message} ({url})") - self.status = status - self.message = message - self.url = url + """One source registration, native acquisition, and existing SQL-owned page progress.""" + + canonical_record_types = (*_ROOTS, "block", "page_property") + # Root alternatives remain independent access authorities. Child alternatives + # represent native enumeration operations; they emit discovered roots, not edges. + canonical_container_parents = { + "page": (None, "data_source"), + "database": (None, "data_source"), + "data_source": (None, "database"), + "block": ("page", "block"), + "page_property": "page", + } + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Versioned acquisition contract; changing it requires explicit cycle restart.""" + return self._cycle_configuration @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: NotionConfig, ) -> NotionSource: - """Create a new Notion source.""" - return cls(auth=auth, logger=logger, http_client=http_client) - - async def validate(self) -> None: - """Validate credentials by pinging the Notion current-user endpoint.""" - await self._get("https://api.notion.com/v1/users/me") - - # ------------------------------------------------------------------ - # HTTP helpers - # ------------------------------------------------------------------ + """Build a native managed/direct-auth source without acquiring additional grants.""" + instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.config = config + fingerprint = ( + f"notion:{API_VERSION}:{FIELD_SET_VERSION}:partial-native-roots-blocks-properties:" + f"{config.expected_workspace_id}:{config.expected_bot_id}" + ) + instance._cycle_configuration = CycleConfiguration.from_source( + fingerprint=hashlib.sha256(fingerprint.encode()).hexdigest(), + record_types=cls.canonical_record_types, + container_parents=cls.canonical_container_parents, + completion_policies={kind: "discovery_with_validation" for kind in _ROOTS}, + ) + return instance @retry( stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), reraise=True, ) - async def _get(self, url: str) -> dict: - """Make an authenticated GET request to the Notion API.""" - token = await self.auth.get_token() - headers = { - "Authorization": f"Bearer {token}", - "Notion-Version": "2022-06-28", - } - - response = await self.http_client.get(url, headers=headers, timeout=self.TIMEOUT_SECONDS) - + async def _request( + self, + path: str, + body: dict[str, JsonValue] | None = None, + *, + params: dict[str, str | int] | None = None, + ) -> dict[str, JsonValue] | None: + headers = {**await authorization_headers(self.auth), "Notion-Version": API_VERSION} + method = self.http_client.get if body is None else self.http_client.post + kwargs = {"params": params} if body is None else {"json": body} + response = await method(f"https://api.notion.com/v1/{path}", headers=headers, **kwargs) if response.status_code == 401 and self.auth.supports_refresh: - self.logger.warning("Received 401 from Notion — attempting token refresh") - new_token = await self.auth.force_refresh() - headers["Authorization"] = f"Bearer {new_token}" - response = await self.http_client.get( - url, headers=headers, timeout=self.TIMEOUT_SECONDS - ) - - if response.status_code != 200: - msg = "" - try: - body_json = response.json() - if isinstance(body_json, dict): - msg = str(body_json.get("message", "")) - except Exception: - pass - - is_pages = "/v1/pages/" in url - is_databases = "/v1/databases/" in url - - if is_pages and response.status_code in (403, 404): - raise NotionSource.NotionAccessError( - response.status_code, msg or "access denied", url - ) - if is_databases and ( - response.status_code in (403, 404) - or ( - response.status_code == 400 - and "does not contain any data sources" in (msg or "").lower() - ) - ): - raise NotionSource.NotionAccessError( - response.status_code, msg or "validation_error", url - ) - + headers = { + **await authorization_headers(self.auth, refresh=True), + "Notion-Version": API_VERSION, + } + response = await method(f"https://api.notion.com/v1/{path}", headers=headers, **kwargs) + if ( + response.status_code == 404 + and _Error.model_validate(response.json()).code == "object_not_found" + ): + return None raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + response, source_short_name="notion", token_provider_kind=self.auth.provider_kind ) - return response.json() + result = response.json() + if not isinstance(result, dict): + raise ValueError("Notion returned a non-object response") + return result - @retry( - stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, - reraise=True, - ) - async def _post(self, url: str, json_data: dict) -> dict: - """Make an authenticated POST request to the Notion API.""" - token = await self.auth.get_token() - headers = { - "Authorization": f"Bearer {token}", - "Notion-Version": "2022-06-28", - } - - response = await self.http_client.post( - url, headers=headers, json=json_data, timeout=self.TIMEOUT_SECONDS - ) - - if response.status_code == 401 and self.auth.supports_refresh: - self.logger.warning("Received 401 from Notion POST — attempting token refresh") - new_token = await self.auth.force_refresh() - headers["Authorization"] = f"Bearer {new_token}" - response = await self.http_client.post( - url, headers=headers, json=json_data, timeout=self.TIMEOUT_SECONDS + async def validate(self) -> None: + """Verify identity independently of content discovery capabilities.""" + payload = await self._request("users/me") + principal = _Principal.model_validate(payload) + if (principal.bot.workspace_id, principal.id) != ( + self.config.expected_workspace_id, + self.config.expected_bot_id, + ): + raise SourceAuthError( + "Notion workspace or connection bot does not match the configured account", + source_short_name="notion", + status_code=200, + token_provider_kind=self.auth.provider_kind, ) - raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, + async def _retrieve(self, kind: str, native_id: str) -> dict[str, JsonValue] | None: + native_id = str(UUID(native_id)) + payload = await self._request(f"{_ENDPOINTS[kind]}/{native_id}") + if payload is not None: + parsed = _Object.model_validate(payload) + if parsed.object != kind or str(parsed.id) != native_id: + raise ValueError("Notion returned the wrong exact object identity") + return payload + + @staticmethod + def _record( + kind: str, payload: dict[str, JsonValue], *, parent: RecordIdentity | None = None + ) -> CaptureRecord: + parsed = _Object.model_validate(payload) + if parsed.object != kind: + raise ValueError("Notion returned an unexpected object kind") + return CaptureRecord( + identity=RecordIdentity(record_type=kind, native_id=str(parsed.id)), + parent=parent, + allow_reparent=parent is not None, + payload=payload, + payload_schema_version=FIELD_SET_VERSION, + completeness="partial", + source_created_at=parsed.created_time, + source_updated_at=parsed.last_edited_time, + observed_at=datetime.now(timezone.utc), + kind="delete" if parsed.in_trash else "upsert", + removal_reason="provider_deleted" if parsed.in_trash else None, ) - return response.json() - - # ------------------------------------------------------------------ - # Search / query helpers - # ------------------------------------------------------------------ - - async def _search_objects( # noqa: C901 - self, object_type: str - ) -> AsyncGenerator[dict, None]: - """Search for objects of a specific type, excluding archived objects.""" - url = "https://api.notion.com/v1/search" - has_more = True - start_cursor = None - - total_found = 0 - total_filtered = 0 - - while has_more: - json_data: Dict[str, Any] = { - "filter": {"property": "object", "value": object_type}, - "page_size": 100, - } - if start_cursor: - json_data["start_cursor"] = start_cursor - - try: - response = await self._post(url, json_data) - results = response.get("results", []) - total_found += len(results) - - self.logger.debug(f"Search returned {len(results)} {object_type}(s) in this batch") - - filtered_count = 0 - for obj in results: - is_archived = obj.get("archived", False) - is_trashed = obj.get("in_trash", False) - - if is_archived or is_trashed: - filtered_count += 1 - total_filtered += 1 - else: - yield obj - - if filtered_count > 0: - self.logger.warning( - f"Filtered {filtered_count} {object_type}(s) in this batch " - f"(archived or trashed)" - ) - has_more = response.get("has_more", False) - start_cursor = response.get("next_cursor") - - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Search access issue for {object_type}: {e}") - raise - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error searching for {object_type}: {e}") - raise - - yielded_count = total_found - total_filtered - self.logger.debug( - f"Search complete for {object_type}: " - f"found={total_found}, filtered={total_filtered}, yielded={yielded_count}" + @staticmethod + def _unavailable(record: SourceRecord) -> CaptureRecord: + return CaptureRecord( + identity=record.identity, + parent=record.parent, + payload={}, + kind="delete", + removal_reason="scope_removed", + completeness="partial", + observed_at=datetime.now(timezone.utc), ) - async def _query_database_pages( # noqa: C901 - self, database_id: str - ) -> AsyncGenerator[dict, None]: - """Query all pages in a database, excluding archived pages.""" - url = f"https://api.notion.com/v1/databases/{database_id}/query" - has_more = True - start_cursor = None - - total_found = 0 - total_filtered = 0 - - while has_more: - json_data: Dict[str, Any] = {"page_size": 100} - if start_cursor: - json_data["start_cursor"] = start_cursor - - try: - response = await self._post(url, json_data) - results = response.get("results", []) - total_found += len(results) - - filtered_count = 0 - for page in results: - is_archived = page.get("archived", False) - is_trashed = page.get("in_trash", False) - - if is_archived or is_trashed: - filtered_count += 1 - total_filtered += 1 - else: - yield page - - if filtered_count > 0: - self.logger.warning( - f"Filtered {filtered_count} page(s) in this batch (archived or trashed)" - ) - - has_more = response.get("has_more", False) - start_cursor = response.get("next_cursor") - - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Database query access issue for {database_id}: {e}") - raise - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error querying database {database_id}: {e}") - raise - - yielded_count = total_found - total_filtered - self.logger.debug( - f"Database query complete for {database_id}: " - f"found={total_found}, filtered={total_filtered}, yielded={yielded_count}" + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Search omission is repaired by an exact native read, never inferred deletion.""" + if record.identity.record_type not in _ROOTS or record.parent is not None: + raise ValueError("Notion known-object refresh requires an independent root") + payload = await self._retrieve(record.identity.record_type, record.identity.native_id) + return ( + self._unavailable(record) + if payload is None + else self._record(record.identity.record_type, payload) ) - # ------------------------------------------------------------------ - # Child database processing - # ------------------------------------------------------------------ - - async def _process_child_databases( # noqa: C901 - self, files: FileService | None = None - ) -> AsyncGenerator[BaseEntity, None]: - """Process child databases discovered during page content extraction.""" - self.logger.debug("Processing child databases") - - while self._child_databases_to_process: - unprocessed_databases = [ - db_id - for db_id in self._child_databases_to_process - if db_id not in self._processed_databases - ] - - if not unprocessed_databases: - break - - self.logger.debug(f"Processing {len(unprocessed_databases)} child databases") - - for database_id in unprocessed_databases: - try: - self.logger.debug(f"Processing child database: {database_id}") - - schema = await self._get(f"https://api.notion.com/v1/databases/{database_id}") - - breadcrumbs = self._child_database_breadcrumbs.get(database_id, []) - - database_entity = NotionDatabaseEntity.from_api(schema, breadcrumbs=breadcrumbs) - yield database_entity - self._processed_databases.add(database_id) - - async for page in self._query_database_pages(database_id): - page_id = page["id"] - if page_id in self._processed_pages: - continue - - try: - db_breadcrumb = Breadcrumb( - entity_id=database_id, - name=database_entity.title, - entity_type=NotionDatabaseEntity.__name__, - ) - page_breadcrumbs = breadcrumbs + [db_breadcrumb] - - ( - page_entity, - file_entities, - ) = await self._create_comprehensive_page_entity( - page, page_breadcrumbs, database_id, schema - ) - yield page_entity - - for file_entity in file_entities: - processed = await self._process_and_yield_file(file_entity, files) - if processed: - yield processed - - self._processed_pages.add(page_id) - - except NotionSource.NotionAccessError as e: - self.logger.warning( - f"Access issue processing child database page {page_id}: {e}" - ) - continue - except SourceAuthError: - raise - except Exception as e: - self.logger.warning( - f"Error processing child database page {page_id}: {e}" - ) - continue - - except NotionSource.NotionAccessError as e: - self.logger.warning( - f"Child database {database_id} not accessible ({e.status}). Skipping." - ) - self._processed_databases.add(database_id) - continue - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing child database {database_id}: {e}") - self._processed_databases.add(database_id) - continue - - # ------------------------------------------------------------------ - # Breadcrumbs - # ------------------------------------------------------------------ - - async def _build_page_breadcrumbs(self, page: dict) -> List[Breadcrumb]: - """Build breadcrumbs for a page by traversing up the parent hierarchy.""" - breadcrumbs: List[Breadcrumb] = [] - current_page = page - - while True: - parent = current_page.get("parent", {}) - parent_type = parent.get("type", "") - - if parent_type == "page_id": - parent_id = parent.get("page_id") - try: - parent_page = await self._get(f"https://api.notion.com/v1/pages/{parent_id}") - parent_title = _extract_page_title(parent_page) or "Untitled Page" - breadcrumbs.insert( - 0, - Breadcrumb( - entity_id=parent_id, - name=parent_title, - entity_type=NotionPageEntity.__name__, - ), - ) - current_page = parent_page - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Could not fetch parent page {parent_id}: {e}") - break - elif parent_type == "database_id": - break - else: - break - - return breadcrumbs - - # ------------------------------------------------------------------ - # Page entity creation with content aggregation - # ------------------------------------------------------------------ + async def confirm_absent(self, record: SourceRecord) -> None: + """Only native block-child listings establish their exact-owner absence.""" + if record.identity.record_type != "block" or record.parent is None: + raise ValueError("Notion discovery requires exact known-object refresh") + payload = await self._retrieve("block", record.identity.native_id) + if payload is None: + return + block = _Block.model_validate(payload) + if block.in_trash or self._block_parent(block) != record.parent: + return + raise ValueError("Notion omitted a block still readable under its captured owner") + + @staticmethod + def _block_parent(block: _Block) -> RecordIdentity: + if block.parent.type == "page_id" and block.parent.page_id is not None: + return RecordIdentity(record_type="page", native_id=str(block.parent.page_id)) + if block.parent.type == "block_id" and block.parent.block_id is not None: + return RecordIdentity(record_type="block", native_id=str(block.parent.block_id)) + raise ValueError("Notion block has an unsupported or missing native owner") + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Notion UUIDs are global; do not rewrite native object identity when it moves.""" + if record_type not in self.capture_cycle_configuration.children_of( + parent.identity.record_type + ): + raise ValueError("Unsupported Notion enumeration scope") + return CompletedScope( + record_type=record_type, + parent=parent.identity, + container_id=parent.identity.native_id if record_type == "page_property" else None, + ) - async def _create_comprehensive_page_entity( + async def capture_page( self, - page: dict, - breadcrumbs: List[Breadcrumb], - database_id: Optional[str] = None, - database_schema: Optional[dict] = None, - ) -> Tuple[NotionPageEntity, List[NotionFileEntity]]: - """Create a comprehensive page entity with full aggregated content.""" - page_id = page["id"] - title = _extract_page_title(page) - - self.logger.debug(f"Processing page: {title} ({page_id})") - - self._child_databases_to_process.clear() - - content_result = await self._aggregate_page_content(page_id, breadcrumbs) - - property_entities: List[NotionPropertyEntity] = [] - formatted_properties: Dict[str, Any] = {} - if database_id and database_schema: - property_entities = await self._extract_page_properties( - page, database_id, database_schema - ) - formatted_properties = self._create_formatted_properties_dict( - page.get("properties", {}), database_schema.get("properties", {}) + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Return bounded observations; SQL commits them together with this continuation.""" + if scope.record_type == "page_property": + if ( + parent is None + or parent.identity != scope.parent + or scope != self.child_scope(parent, "page_property") + ): + raise ValueError("Notion property scope has a different native page owner") + return await self._property_page( + parent, _PropertyProgress.model_validate(continuation.value), files ) - - properties_text = self._generate_properties_text_for_page(formatted_properties, title) - - page_entity = NotionPageEntity.from_api( - page, - breadcrumbs=breadcrumbs, - content=content_result["content"], - formatted_properties=formatted_properties, - properties_text=properties_text, - property_entities=property_entities, - content_blocks_count=content_result["blocks_count"], - max_depth=content_result["max_depth"], + progress = _Progress.model_validate(continuation.value) + if scope.container_id is not None: + raise ValueError("Notion native identities do not have a synthetic container") + if scope.parent is None: + if parent is not None or scope.record_type not in _ROOTS: + raise ValueError("Invalid Notion discovery scope") + return await self._search(scope.record_type, progress) + if parent is None or parent.identity != scope.parent: + raise ValueError("Notion enumeration owner is missing or mismatched") + self.child_scope(parent, scope.record_type) + if scope.record_type == "block": + return await self._blocks(parent, progress) + if parent.identity.record_type == "database": + return await self._database_sources(parent, progress) + if scope.record_type == "database": + located = _Located.model_validate(parent.payload) + native = located.parent.database_id + roots = await self._roots("database", [str(native)] if native else []) + return self._page((), progress, True, roots) + return await self._query_rows(parent, progress) + + @staticmethod + def _page( + records: Sequence[CaptureRecord], + progress: _Progress | _PropertyProgress, + final: bool, + discovered: Sequence[CaptureRecord] = (), + ) -> CapturePage: + return CapturePage( + records=tuple(records), + discovered_records=tuple(discovered), + continuation=ScanContinuation(value=progress.model_dump(mode="json")), + final=final, ) - files_with_breadcrumbs = [] - for file_entity in content_result["files"]: - file_entity.breadcrumbs = breadcrumbs.copy() - files_with_breadcrumbs.append(file_entity) + @staticmethod + def _following(page: _List, progress: _Progress) -> tuple[str | None, tuple[str, ...]]: + if page.has_more: + if not page.next_cursor or page.next_cursor == progress.cursor: + raise ValueError("Notion returned a missing or repeated cursor") + digest = hashlib.sha256(page.next_cursor.encode()).hexdigest() + if digest in progress.cursor_hashes: + raise ValueError("Notion pagination entered a cursor cycle") + # Detect recent cycles without imposing a total page-count ceiling. + # Longer-period loops remain subject to the existing runtime limits. + return page.next_cursor, (*progress.cursor_hashes[-511:], digest) + if page.next_cursor is not None: + raise ValueError("Notion returned an inconsistent terminal cursor") + return None, progress.cursor_hashes + + async def _roots(self, kind: str, identities: list[str]) -> list[CaptureRecord]: + result = [] + for native in dict.fromkeys(identities): + payload = await self._retrieve(kind, native) + if payload is not None: + record = self._record(kind, payload) + # Side admissions cannot claim deletion of a different root inventory. + if record.kind == "upsert": + result.append(record) + return result + + async def _search(self, kind: str, progress: _Progress) -> CapturePage: + if kind == "database": + return self._page((), progress, True) + body: dict[str, JsonValue] = { + "page_size": _PAGE_SIZE, + "filter": {"property": "object", "value": kind}, + } + if progress.cursor: + body["start_cursor"] = progress.cursor + page = _List.model_validate(await self._request("search", body)) + if page.request_status and page.request_status.type == "incomplete": + raise SourceError( + "Notion search was capped; discovery has not completed", source_short_name="notion" + ) + records = [] + for item in page.results: + parsed = _Object.model_validate(item) + if parsed.object != kind: + raise ValueError("Notion search returned a different filtered object type") + payload = await self._retrieve(kind, str(parsed.id)) + if payload is not None: + records.append(self._record(kind, payload)) + cursor, cursor_hashes = self._following(page, progress) + return self._page( + records, + progress.model_copy(update={"cursor": cursor, "cursor_hashes": cursor_hashes}), + cursor is None, + ) - self.logger.debug( - f"Page entity created: {len(content_result['content'])} chars, " - f"{content_result['blocks_count']} blocks, " - f"{len(files_with_breadcrumbs)} files, " - f"max depth {content_result['max_depth']}" + async def _database_sources(self, parent: SourceRecord, progress: _Progress) -> CapturePage: + # Revalidate exact current database, not a stale embedded data-source inventory. + payload = await self._retrieve("database", parent.identity.native_id) + if payload is None: + raise ScopeAccessLost("Notion database is unavailable", removal_reason="scope_removed") + database = _Database.model_validate(payload) + if database.in_trash: + raise ScopeAccessLost("Notion database is in trash", removal_reason="scope_removed") + identities = database.data_sources + ids = [str(_Id.model_validate(item).id) for item in identities] + # One bounded response is re-read on resume. A changed list must restart rather + # than skip objects by an offset into a different inventory. + digest = hashlib.sha256("\n".join(ids).encode()).hexdigest() + if progress.cursor and progress.cursor != digest: + raise InvalidScanContinuation("Notion database inventory changed; restart this scope") + roots = await self._roots( + "data_source", ids[progress.offset : progress.offset + _PAGE_SIZE] + ) + offset = progress.offset + _PAGE_SIZE + return self._page( + (), + progress.model_copy(update={"offset": min(offset, len(ids)), "cursor": digest}), + offset >= len(ids), + roots, ) - return page_entity, files_with_breadcrumbs - - async def _aggregate_page_content( - self, page_id: str, page_breadcrumbs: List[Breadcrumb] - ) -> Dict[str, Any]: - """Aggregate all content from a page into a single markdown string.""" - content_parts: List[str] = [] - files: List[NotionFileEntity] = [] - blocks_count = 0 - max_depth = 0 - - async for block_content in self._extract_blocks_recursive(page_id, 0, page_breadcrumbs): - content_parts.append(block_content["content"]) - files.extend(block_content["files"]) - blocks_count += 1 - max_depth = max(max_depth, block_content["depth"]) - self._stats["total_blocks_processed"] += 1 - - if max_depth > self._stats["max_page_depth"]: - self._stats["max_page_depth"] = max_depth - - self._stats["total_files_found"] += len(files) - - full_content = "\n\n".join(filter(None, content_parts)) - cleaned_content = self.clean_content_for_embedding(full_content) - - return { - "content": cleaned_content, - "files": files, - "blocks_count": blocks_count, - "max_depth": max_depth, + async def _query_rows(self, parent: SourceRecord, progress: _Progress) -> CapturePage: + body: dict[str, JsonValue] = { + "page_size": _PAGE_SIZE, + "sorts": [{"timestamp": "created_time", "direction": "ascending"}], } - - async def _extract_blocks_recursive( - self, - block_id: str, - depth: int, - page_breadcrumbs: List[Breadcrumb], - ) -> AsyncGenerator[Dict[str, Any], None]: - """Recursively extract and format blocks into markdown content.""" - url = f"https://api.notion.com/v1/blocks/{block_id}/children" - has_more = True - start_cursor = None - - while has_more: - url_with_params = url if not start_cursor else f"{url}?start_cursor={start_cursor}" - - try: - response = await self._get(url_with_params) - if not self._is_valid_response(response, block_id): - break - - blocks = response.get("results", []) - async for result in self._process_blocks(blocks, block_id, depth, page_breadcrumbs): - yield result - - has_more = response.get("has_more", False) - start_cursor = response.get("next_cursor") - - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Access issue extracting blocks from {block_id}: {e}") - break - except SourceAuthError: - raise - except Exception as e: - self.logger.warning( - f"Error extracting blocks from {block_id}: {type(e).__name__}: {e}" + if progress.cursor: + body["start_cursor"] = progress.cursor + if progress.window_start: + body["filter"] = { + "timestamp": "created_time", + "created_time": {"on_or_after": progress.window_start.isoformat()}, + } + payload = await self._request(f"data_sources/{parent.identity.native_id}/query", body) + if payload is None: + raise ScopeAccessLost( + "Notion data source is unavailable", removal_reason="scope_removed" + ) + page = _List.model_validate(payload) + roots = [] + last = progress.last_created + for item in page.results: + parsed = _Object.model_validate(item) + if parsed.object not in {"page", "data_source"}: + raise ValueError("Notion data source returned an unexpected row type") + if last is not None and parsed.created_time < last: + raise ValueError("Notion data source violated created-time ordering") + last = parsed.created_time + roots.extend(await self._roots(parsed.object, [str(parsed.id)])) + cursor, cursor_hashes = self._following(page, progress) + if ( + page.request_status + and page.request_status.type == "incomplete" + and page.request_status.incomplete_reason != "query_result_limit_reached" + ): + raise SourceError( + "Notion query was incomplete for an unsupported reason", source_short_name="notion" + ) + capped = progress.capped or bool( + page.request_status and page.request_status.type == "incomplete" + ) + next_progress = progress.model_copy( + update={ + "cursor": cursor, + "cursor_hashes": cursor_hashes, + "last_created": last, + "capped": capped, + } + ) + if cursor is None and capped: + if last is None or ( + progress.window_start is not None and last <= progress.window_start + ): + raise SourceError( + "Notion query limit cannot be split by creation time; capture is incomplete", + source_short_name="notion", ) - break - - def _is_valid_response(self, response: Any, block_id: str) -> bool: - """Validate API response format.""" - if not response or not isinstance(response, dict): - self.logger.warning( - f"Invalid response format for blocks from {block_id}: {type(response)}" + next_progress = _Progress(window_start=last) + return self._page((), next_progress, False, roots) + return self._page((), next_progress, cursor is None, roots) + + async def _blocks(self, parent: SourceRecord, progress: _Progress) -> CapturePage: + if parent.identity.record_type == "block": + block = _Block.model_validate(parent.payload) + if ( + not block.has_children + or block.type in {"child_page", "child_database"} + or (block.synced_block and block.synced_block.get("synced_from") is not None) + ): + return self._page((), progress, True) + params: dict[str, str | int] = {"page_size": _PAGE_SIZE} + if progress.cursor: + params["start_cursor"] = progress.cursor + payload = await self._request(f"blocks/{parent.identity.native_id}/children", params=params) + if payload is None: + raise ScopeAccessLost( + "Notion block owner is unavailable", removal_reason="scope_removed" ) - return False - return True - - async def _process_blocks( - self, - blocks: List[dict], - block_id: str, - depth: int, - page_breadcrumbs: List[Breadcrumb], - ) -> AsyncGenerator[Dict[str, Any], None]: - """Process a list of blocks and their children.""" - for block in blocks: - if not block or not isinstance(block, dict): - self.logger.warning(f"Skipping invalid block in {block_id}: {type(block)}") - continue - - block_result = await self._format_block_content(block, depth, page_breadcrumbs) - if block_result and block_result.get("content"): - yield block_result - - if block.get("has_children", False) and block.get("id"): - async for child_result in self._extract_blocks_recursive( - block["id"], depth + 1, page_breadcrumbs - ): - yield child_result - - async def _format_block_content( - self, block: dict, depth: int, page_breadcrumbs: List[Breadcrumb] - ) -> Dict[str, Any]: - """Format a single block into markdown content.""" - if not self._validate_block(block): - return {"content": "", "files": [], "depth": depth} - - block_type = block.get("type", "") - if not block_type: - self.logger.warning(f"Block missing type field: {block.get('id', 'unknown')}") - return {"content": "", "files": [], "depth": depth} - - block_content = block.get(block_type, {}) or {} - content, files = self._dispatch_block_formatter( - block_type, block_content, block, depth, page_breadcrumbs + page = _List.model_validate(payload) + if page.request_status and page.request_status.type == "incomplete": + raise ValueError("Notion block enumeration was incomplete") + records, roots = [], [] + for item in page.results: + block = _Block.model_validate(item) + if self._block_parent(block) != parent.identity: + raise ValueError("Notion block has a different native owner") + records.append(self._record("block", item, parent=parent.identity)) + target = {"child_page": "page", "child_database": "database"}.get(block.type) + if target and not block.in_trash: + roots.extend(await self._roots(target, [str(block.id)])) + cursor, cursor_hashes = self._following(page, progress) + return self._page( + records, + progress.model_copy(update={"cursor": cursor, "cursor_hashes": cursor_hashes}), + cursor is None, + roots, ) - return {"content": content, "files": files, "depth": depth} - - def _validate_block(self, block: dict) -> bool: - """Validate block structure.""" - if not block or not isinstance(block, dict): - self.logger.warning(f"Invalid block format: {type(block)}") - return False - return True - - def _dispatch_block_formatter( + async def _property_inventory( self, - block_type: str, - block_content: dict, - block: dict, - depth: int, - page_breadcrumbs: List[Breadcrumb], - ) -> Tuple[str, List[NotionFileEntity]]: - """Dispatch to appropriate block formatter based on type.""" - if block_type == "paragraph": - return self._extract_rich_text_markdown(block_content.get("rich_text", [])), [] - if block_type in ["heading_1", "heading_2", "heading_3"]: - return self._format_heading_block(block_content, block_type), [] - if block_type in ["bulleted_list_item", "numbered_list_item", "to_do"]: - return self._format_list_blocks(block_content, block_type, depth), [] - if block_type in ["quote", "callout", "code"]: - return self._format_text_blocks(block_content, block_type), [] - if block_type in ["image", "video", "file", "pdf"]: - return self._format_file_block(block_content, block, block_type) - if block_type in ["embed", "bookmark", "equation", "divider"]: - return self._format_simple_blocks(block_content, block_type), [] - if block_type in ["child_page", "child_database"]: - return ( - self._format_child_blocks(block_content, block, block_type, page_breadcrumbs), - [], + page_id: str, + ) -> tuple[str, list[tuple[str, dict[str, JsonValue]]], datetime]: + payload = await self._retrieve("page", page_id) + if payload is None or _Object.model_validate(payload).in_trash: + raise ScopeAccessLost( + "Notion property page is unavailable", removal_reason="scope_removed" ) - return self._format_other_blocks(block_content, block_type), [] - - def _format_heading_block(self, block_content: dict, block_type: str) -> str: - """Format heading blocks.""" - level = int(block_type.split("_")[1]) - text = self._extract_rich_text_markdown(block_content.get("rich_text", [])) - prefix = "▸ " if block_content.get("is_toggleable", False) else "" - return f"{'#' * level} {prefix}{text}" - - def _format_list_blocks(self, block_content: dict, block_type: str, depth: int) -> str: - """Format list and todo blocks.""" - text = self._extract_rich_text_markdown(block_content.get("rich_text", [])) - indent = " " * depth - - if block_type == "bulleted_list_item": - return f"{indent}- {text}" - elif block_type == "numbered_list_item": - return f"{indent}1. {text}" - else: # to_do - checkbox = "- [x]" if block_content.get("checked", False) else "- [ ]" - return f"{indent}{checkbox} {text}" - - def _format_text_blocks(self, block_content: dict, block_type: str) -> str: - """Format quote, callout, and code blocks.""" - if block_type == "quote": - text = self._extract_rich_text_markdown(block_content.get("rich_text", [])) - return f"> {text}" - elif block_type == "callout": - return self._format_callout_block(block_content) - else: # code - return self._format_code_block(block_content) - - def _format_simple_blocks(self, block_content: dict, block_type: str) -> str: - """Format simple blocks like embed, bookmark, equation, divider.""" - if block_type in ["embed", "bookmark"]: - url = block_content.get("url", "") - caption = _extract_rich_text_plain(block_content.get("caption", [])) - if len(url) > 200 or "?" in url and len(url.split("?", 1)[1]) > 100: - content = f"[{block_type.title()}]" - else: - content = f"[{block_type.title()}]({url})" - if caption: - content += f" - {caption}" - return content - elif block_type == "equation": - expression = block_content.get("expression", "") - return f"$$\n{expression}\n$$" - elif block_type == "divider": - return "---" - else: - return "**Table of Contents**" - - def _format_child_blocks( - self, block_content: dict, block: dict, block_type: str, page_breadcrumbs: List[Breadcrumb] - ) -> str: - """Format child page and database blocks.""" - if block_type == "child_page": - title = block_content.get("title", "Untitled Page") - return f"📄 **[{title}]** (Child Page)" - else: - return self._format_child_database_block(block_content, block, page_breadcrumbs) - - def _format_other_blocks(self, block_content: dict, block_type: str) -> str: - """Format other block types including table, column, etc.""" - if block_type in ["table", "column_list"]: - return f"**[{block_type.replace('_', ' ').title()}]**" - elif block_type in ["table_row", "column"]: - return "" - elif block_type == "unsupported": - return "*[Unsupported block type]*" - else: - return self._format_unknown_block(block_content, block_type) - - def _format_callout_block(self, block_content: dict) -> str: - """Format callout blocks.""" - icon = block_content.get("icon", {}) - icon_text = icon.get("emoji", "💡") if icon.get("type") == "emoji" else "💡" - text = self._extract_rich_text_markdown(block_content.get("rich_text", [])) - return f"**{icon_text} {text}**" - - def _format_code_block(self, block_content: dict) -> str: - """Format code blocks.""" - language = block_content.get("language", "") - code_text = _extract_rich_text_plain(block_content.get("rich_text", [])) - caption = _extract_rich_text_plain(block_content.get("caption", [])) - content = f"```{language}\n{code_text}\n```" - if caption: - content += f"\n*{caption}*" - return content - - def _format_file_block( - self, block_content: dict, block: dict, block_type: str - ) -> Tuple[str, List[NotionFileEntity]]: - """Format file blocks and return content and file entities.""" - file_entity = NotionFileEntity.from_api( - block_content, parent_id=block["id"], breadcrumbs=[] + page = _PageProperties.model_validate(payload) + rows = sorted( + page.properties.items(), key=lambda item: _Property.model_validate(item[1]).id ) - files = [file_entity] - caption = _extract_rich_text_plain(block_content.get("caption", [])) - - if block_type == "image": - display_name = file_entity.name if file_entity.name != "Untitled File" else "Image" - content = f"[Image: {display_name}]" - else: - content = f"[File: {file_entity.name}]" - - if caption: - content += f" - {caption}" - - return content, files - - def _format_child_database_block( - self, block_content: dict, block: dict, page_breadcrumbs: List[Breadcrumb] - ) -> str: - """Format child database blocks.""" - title = block_content.get("title", "Untitled Database") - database_id = block["id"] - - self._child_databases_to_process.add(database_id) - - child_db_breadcrumbs = page_breadcrumbs.copy() - self._child_database_breadcrumbs[database_id] = child_db_breadcrumbs - - self._stats["child_databases_found"] += 1 - return f"🗃️ **[{title}]** (Child Database)" - - def _format_unknown_block(self, block_content: dict, block_type: str) -> str: - """Format unknown block types.""" - if "rich_text" in block_content: - return self._extract_rich_text_markdown(block_content.get("rich_text", [])) + identities = [ + (_Property.model_validate(value).id, name, _Property.model_validate(value).type) + for name, value in rows + ] + if len({item[0] for item in identities}) != len(identities): + raise ValueError("Notion page contains duplicate property IDs") + material = json.dumps( + [page.last_edited_time.isoformat(), identities], ensure_ascii=False + ).encode() + return hashlib.sha256(material).hexdigest(), rows, page.last_edited_time + + @staticmethod + def _property_response( + payload: dict[str, JsonValue], prop: _Property, progress: _Progress + ) -> tuple[_Progress, bool, Literal["available", "unsupported"]]: + if payload.get("object") == "property_item": + if progress.cursor is not None: + raise InvalidScanContinuation( + "Notion property changed response shape while reading" + ) + item = _PropertyItem.model_validate(payload) + metadata = item + final = True + following = progress else: - return f"*[{block_type.replace('_', ' ').title()}]*" - - # ------------------------------------------------------------------ - # Rich text extraction - # ------------------------------------------------------------------ - - def _extract_rich_text_markdown(self, rich_text: List[dict]) -> str: - """Extract rich text and convert to markdown formatting.""" - if not rich_text or not isinstance(rich_text, list): - return "" - - result_parts = [] - for text_obj in rich_text: - if not text_obj or not isinstance(text_obj, dict): - continue - - text = text_obj.get("plain_text", "") - if not text: - continue - - annotations = text_obj.get("annotations") or {} - href = text_obj.get("href") - formatted_text = self._apply_markdown_annotations(text, annotations, href) - result_parts.append(formatted_text) - - return "".join(result_parts) - - def _apply_markdown_annotations(self, text: str, annotations: dict, href: str = None) -> str: - """Apply markdown formatting based on annotations.""" - if annotations.get("bold"): - text = f"**{text}**" - if annotations.get("italic"): - text = f"*{text}*" - if annotations.get("strikethrough"): - text = f"~~{text}~~" - if annotations.get("underline"): - text = f"{text}" - if annotations.get("code"): - text = f"`{text}`" - if href: - text = f"[{text}]({href})" - return text - - # ------------------------------------------------------------------ - # Property extraction - # ------------------------------------------------------------------ - - async def _extract_page_properties( - self, page: dict, database_id: str, database_schema: dict - ) -> List[NotionPropertyEntity]: - """Extract database page properties as structured entities.""" - page_id = page["id"] - page_properties = page.get("properties", {}) - schema_properties = database_schema.get("properties", {}) - - property_entities = [] - - for prop_name, prop_value in page_properties.items(): - if prop_name in schema_properties: - schema_prop = schema_properties[prop_name] - - try: - property_entity = self._create_property_entity( - prop_name, prop_value, schema_prop, page_id, database_id - ) - property_entities.append(property_entity) - - except Exception as e: - self.logger.warning( - f"Error processing property {prop_name} for page {page_id}: {e}" - ) - continue - - return property_entities - - def _create_property_entity( - self, prop_name: str, prop_value: dict, schema_prop: dict, page_id: str, database_id: str - ) -> NotionPropertyEntity: - """Create a property entity from page property data.""" - prop_type = prop_value.get("type", "") - formatted_value = self._format_property_value(prop_value, prop_type) - schema_prop_id = schema_prop.get("id", prop_name) - property_key = f"{page_id}_{schema_prop_id}" - - return NotionPropertyEntity( - entity_id=property_key, - breadcrumbs=[], - name=prop_name, - created_at=None, - updated_at=None, - property_key=property_key, - property_id=schema_prop_id, - property_name=prop_name, - property_type=prop_type, - page_id=page_id, - database_id=database_id, - value=prop_value.get(prop_type), - formatted_value=formatted_value, + page = _PropertyList.model_validate(payload) + if page.request_status and page.request_status.type == "incomplete": + raise SourceError( + "Notion property response is incomplete", source_short_name="notion" + ) + metadata = page.property_item + for value in page.results: + item = _PropertyItem.model_validate(value) + # Rollup show_original may return the target property's items. + if prop.type != "rollup" and (item.id != prop.id or item.type != prop.type): + raise ValueError("Notion property list contains another property's values") + cursor, hashes = NotionSource._following(page, progress) + following = progress.model_copy(update={"cursor": cursor, "cursor_hashes": hashes}) + final = cursor is None + if metadata.id != prop.id or metadata.type != prop.type: + raise ValueError("Notion returned a different property identity or type") + calculation = metadata.rollup if prop.type == "rollup" else metadata.formula + if prop.type in {"rollup", "formula"} and calculation is None: + raise ValueError("Notion calculation has no native result metadata") + if final and calculation and calculation.type == "incomplete": + raise SourceError( + "Notion property calculation did not complete", source_short_name="notion" + ) + status = "unsupported" if calculation and calculation.type == "unsupported" else "available" + return following, final, status + + async def _collect_property( + self, page_id: str, prop: _Property, fingerprint: str, page_last_edited_time: datetime + ) -> tuple[bytes, int, Literal["available", "unsupported"]]: + """Retain native pages once each; retry starts this property without partial publication.""" + header = ( + _PropertyArchiveHeader( + page_id=UUID(page_id), + property_id=prop.id, + page_last_edited_time=page_last_edited_time, + ) + .model_dump_json() + .encode() ) - - # ------------------------------------------------------------------ - # Utility methods - # ------------------------------------------------------------------ - - def _format_property_value(self, prop_value: dict, prop_type: str) -> str: - """Format property value for human readability.""" - if not prop_value or prop_type not in prop_value: - return "" - - value = prop_value[prop_type] - - formatters = { - "title": lambda v: _extract_rich_text_plain(v), - "rich_text": lambda v: _extract_rich_text_plain(v), - "number": lambda v: str(v) if v is not None else "", - "url": lambda v: str(v) if v is not None else "", - "email": lambda v: str(v) if v is not None else "", - "phone_number": lambda v: str(v) if v is not None else "", - "checkbox": lambda v: "Yes" if v else "No", - "select": lambda v: self._format_select_properties(v), - "status": lambda v: self._format_select_properties(v), - "multi_select": lambda v: ", ".join([opt.get("name", "") for opt in v]) if v else "", - "date": lambda v: self._format_date_property(v), - "people": lambda v: self._format_people_property(v), - "files": lambda v: f"{len(v)} file(s)" if v else "0 files", - "created_time": lambda v: v or "", - "last_edited_time": lambda v: v or "", - "created_by": lambda v: v.get("name", "Unknown User") if v else "", - "last_edited_by": lambda v: v.get("name", "Unknown User") if v else "", - } - - if prop_type in formatters: - return formatters[prop_type](value) - - return self._format_complex_property_types(prop_type, value) - - def _format_complex_property_types(self, prop_type: str, value: Any) -> str: - """Format complex property types that need special handling.""" - if prop_type == "relation": - return f"{len(value)} relation(s)" if value else "0 relations" - elif prop_type == "rollup": - return self._format_rollup_property(value) - elif prop_type == "formula": - return self._format_formula_property(value) - elif prop_type == "unique_id": - return self._format_unique_id_property(value) - elif prop_type == "verification": - return self._format_verification_property(value) - else: - return str(value) if value else "" - - def _format_unique_id_property(self, value: dict) -> str: - prefix = value.get("prefix", "") - number = value.get("number", "") - return f"{prefix}{number}" if prefix else str(number) - - def _format_verification_property(self, value: dict) -> str: - state = value.get("state", "") - return state.title() if state else "" - - def _format_select_properties(self, value: dict) -> str: - return value.get("name", "") if value else "" - - def _format_date_property(self, value: dict) -> str: - if value and value.get("start"): - start = value["start"] - end = value.get("end") - return f"{start} - {end}" if end else start - return "" - - def _format_people_property(self, value: List[dict]) -> str: - names = [] - for person in value: - if person.get("type") == "person": - names.append(person.get("name", "Unknown")) - elif person.get("type") == "bot": - names.append(person.get("name", "Bot")) - return ", ".join(names) - - def _format_formula_property(self, value: dict) -> str: - formula_type = value.get("type", "") - if formula_type in ["string", "number", "boolean", "date"]: - return str(value.get(formula_type, "")) - return "" - - def _format_rollup_property(self, value: dict) -> str: - rollup_type = value.get("type", "") - if rollup_type in ["string", "number", "boolean", "date"]: - return str(value.get(rollup_type, "")) - elif rollup_type == "array": - return f"{len(value.get('array', []))} item(s)" - return "" - - def _create_formatted_properties_dict( - self, page_properties: dict, database_schema: dict - ) -> Dict[str, Any]: - """Create a clean, formatted properties dictionary for better searchability.""" - formatted: Dict[str, Any] = {} - - for prop_name, prop_value in page_properties.items(): - if not prop_value: - continue - - prop_type = prop_value.get("type", "") - formatted_value = self._format_property_value(prop_value, prop_type) - - if not formatted_value: - continue - - formatted[prop_name] = formatted_value - - if prop_type in ["select", "status", "multi_select"] and prop_name in database_schema: - schema_prop = database_schema[prop_name] - if prop_type == "multi_select": - options = schema_prop.get("multi_select", {}).get("options", []) - else: - options = schema_prop.get(prop_type, {}).get("options", []) - - if options: - formatted[f"{prop_name}_options"] = [ - opt.get("name", "") for opt in options if opt.get("name") - ] - - return formatted - - def _generate_properties_text_for_page( - self, properties: Dict[str, Any], page_title: str - ) -> str: - """Generate human-readable text from properties for embedding.""" - if not properties: - return "" - - text_parts = [] - - priority_keys = [ - "Product Name", - "Name", - "Title", - "Status", - "Priority", - "Launch Status", - "Owner", - "Team", - "Description", - ] - - for key in priority_keys: - if key in properties: - value = properties[key] - if value and str(value).strip(): - if key in ["Product Name", "Name", "Title"] and value == page_title: - continue - text_parts.append(f"{key}: {value}") - - for key, value in properties.items(): - if key not in priority_keys and not key.endswith("_options"): - if value and str(value).strip(): - formatted_key = key.replace("_", " ").title() - text_parts.append(f"{formatted_key}: {value}") - - return " | ".join(text_parts) if text_parts else "" - - # ------------------------------------------------------------------ - # File processing - # ------------------------------------------------------------------ - - async def _process_and_yield_file( # noqa: C901 - self, file_entity: NotionFileEntity, files: FileService | None = None - ) -> Optional[NotionFileEntity]: - """Process a file entity by downloading it and setting local_path.""" - try: - if file_entity.needs_refresh(): - self.logger.warning(f"Skipping file {file_entity.name} - URL expired") - return None - - if not file_entity.url or file_entity.url.startswith("http") is False: - self.logger.debug(f"Skipping file {file_entity.name} - no valid download URL") - return None - - if not files: - return None - - try: - await files.download_from_url( - entity=file_entity, - client=self.http_client, - auth=self.auth, - logger=self.logger, + prefix = header[:-1] + b',"responses":[' + responses: list[bytes] = [] + total = len(prefix) + len(b"]}") + following = _Progress() + while True: + params: dict[str, str | int] = {"page_size": _PAGE_SIZE} + if following.cursor: + params["start_cursor"] = following.cursor + payload = await self._request(f"pages/{page_id}/properties/{prop.id}", params=params) + if payload is None: + # The page may have been removed, or its schema changed. Do not + # withdraw the whole page based solely on a property endpoint404. + current, _, _ = await self._property_inventory(page_id) + if current != fingerprint: + raise InvalidScanContinuation( + "Notion property inventory changed during retrieval" + ) + raise SourceError( + "Notion property is unavailable while its page remains readable", + source_short_name="notion", ) - - if not file_entity.local_path: - raise ValueError(f"Download failed - no local path set for {file_entity.name}") - - self.logger.debug( - f"Successfully downloaded file {file_entity.name} " - f"to local_path: {file_entity.local_path}" + following, final, status = self._property_response(payload, prop, following) + encoded = json.dumps( + payload, ensure_ascii=False, allow_nan=False, separators=(",", ":"), sort_keys=True + ).encode() + total += len(encoded) + bool(responses) + if total > MAX_FILE_SIZE_BYTES: + raise SourceError( + "Notion property exceeds the retained-file size limit; capture is incomplete", + source_short_name="notion", ) - return file_entity - - except FileSkippedException as e: - self.logger.debug(f"Skipping file {file_entity.name}: {e.reason}") - return None - - except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise - self.logger.warning(f"Failed to download file {file_entity.name}: {e}") - return None - - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Access issue processing file {file_entity.name}: {e}") - return None - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing file {file_entity.name}: {e}") - return None - - # ------------------------------------------------------------------ - # Main entry point - # ------------------------------------------------------------------ + responses.append(encoded) + if final: + break + return prefix + b",".join(responses) + b"]}", len(responses), status + + async def _property_page( + self, parent: SourceRecord, progress: _PropertyProgress, files: FileService + ) -> CapturePage: + page_id = parent.identity.native_id + fingerprint, properties, page_last_edited_time = await self._property_inventory(page_id) + if progress.inventory is not None and progress.inventory != fingerprint: + raise InvalidScanContinuation( + "Notion page properties changed; restart their enumeration" + ) + if progress.index > len(properties): + raise ValueError("Notion property continuation is outside its inventory") + if progress.index == len(properties): + return self._page((), progress.model_copy(update={"inventory": fingerprint}), True) + name, native_property = properties[progress.index] + prop = _Property.model_validate(native_property) + archive, count, status = await self._collect_property( + page_id, prop, fingerprint, page_last_edited_time + ) + current, _, _ = await self._property_inventory(page_id) + if current != fingerprint: + raise InvalidScanContinuation("Notion page changed before property capture completed") + blob = await files.store_canonical_blob(archive, media_type="application/json") + record = CaptureRecord( + identity=RecordIdentity( + record_type="page_property", native_id=prop.id, container_id=page_id + ), + parent=parent.identity, + payload=_PropertyManifest( + page_id=UUID(page_id), + property_id=prop.id, + name=name, + property=native_property, + response_count=count, + value_status=status, + page_last_edited_time=page_last_edited_time, + ).model_dump(mode="json"), + payload_schema_version=FIELD_SET_VERSION, + completeness="partial", + blobs=(blob,), + observed_at=datetime.now(timezone.utc), + ) + next_progress = _PropertyProgress(index=progress.index + 1, inventory=fingerprint) + return self._page((record,), next_progress, next_progress.index == len(properties)) - async def generate_entities( # noqa: C901 + async def generate_entities( self, *, cursor: SyncCursor | None = None, files: FileService | None = None, node_selections: list[NodeSelectionData] | None = None, ) -> AsyncGenerator[BaseEntity, None]: - """Generate all entities from Notion using streaming discovery.""" - self.logger.debug("Starting Notion entity generation with content aggregation") - - self._processed_pages: Set[str] = set() - self._processed_databases: Set[str] = set() - self._child_databases_to_process: Set[str] = set() - self._child_database_breadcrumbs: Dict[str, List[Breadcrumb]] = {} - self._stats: Dict[str, int] = { - "api_calls": 0, - "rate_limit_waits": 0, - "databases_found": 0, - "child_databases_found": 0, - "pages_found": 0, - "total_blocks_processed": 0, - "total_files_found": 0, - "max_page_depth": 0, - } - - try: - self.logger.debug("PHASE 1 & 2: Streaming database discovery and schema analysis") - async for entity in self._stream_database_discovery(files): - yield entity - self.logger.debug( - f"Phase 1 & 2 complete: {self._stats['databases_found']} databases found" - ) - - self.logger.debug("PHASE 3: Streaming standalone page discovery") - async for entity in self._stream_page_discovery(files): - yield entity - self.logger.debug( - f"Phase 3 complete: {self._stats['pages_found']} standalone pages found" - ) - - self.logger.debug("PHASE 4: Processing child databases") - async for entity in self._process_child_databases(files): - yield entity - self.logger.debug( - f"Phase 4 complete: {self._stats['child_databases_found']} child databases found" - ) - - self.logger.debug("Notion sync complete. Final stats:") - for key, value in self._stats.items(): - self.logger.debug(f" {key}: {value}") - - except SourceAuthError: - raise - except Exception as e: - self.logger.warning( - f"Error during streaming Notion entity generation: {e}", exc_info=True - ) - raise - - async def _stream_database_discovery( - self, files: FileService | None = None - ) -> AsyncGenerator[BaseEntity, None]: - """Discover databases and delegate per-database processing.""" - self.logger.debug("Streaming database discovery...") - - async for database in self._search_objects("database"): - database_id = database["id"] - if database_id in self._processed_databases: - continue - self._stats["databases_found"] += 1 - async for entity in self._process_single_database(database_id, files): - yield entity - - async def _process_single_database( # noqa: C901 - self, database_id: str, files: FileService | None = None - ) -> AsyncGenerator[BaseEntity, None]: - """Process one database: fetch schema, emit DB entity, pages, files, child DBs.""" - try: - self.logger.debug(f"Fetching schema for database: {database_id}") - schema = await self._get(f"https://api.notion.com/v1/databases/{database_id}") - self._processed_databases.add(database_id) - - database_entity = NotionDatabaseEntity.from_api(schema, breadcrumbs=[]) - yield database_entity - - database_title = _extract_rich_text_plain(schema.get("title", [])) - self.logger.debug(f"Processing pages in database: {database_title}") - - async for page in self._query_database_pages(database_id): - page_id = page["id"] - if page_id in self._processed_pages: - continue - try: - breadcrumbs = [ - Breadcrumb( - entity_id=database_id, - name=database_title or "Untitled Database", - entity_type=NotionDatabaseEntity.__name__, - ) - ] - page_entity, file_entities = await self._create_comprehensive_page_entity( - page, breadcrumbs, database_id, schema - ) - yield page_entity - for file_entity in file_entities: - processed = await self._process_and_yield_file(file_entity, files) - if processed: - yield processed - self._processed_pages.add(page_id) - async for child_entity in self._process_child_databases(files): - yield child_entity - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Access issue processing database page {page_id}: {e}") - continue - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing database page {page_id}: {e}") - continue - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Access issue processing database {database_id}: {e}") - return - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing database {database_id}: {e}") - return - - async def _stream_page_discovery( - self, files: FileService | None = None - ) -> AsyncGenerator[BaseEntity, None]: - """Stream page discovery and immediately yield page entities.""" - self.logger.debug("Streaming page discovery...") - - async for page in self._search_objects("page"): - page_id = page["id"] - if page_id in self._processed_pages: - continue - - parent = page.get("parent", {}) - parent_type = parent.get("type", "") - - if parent_type == "database_id": - continue - - self._stats["pages_found"] += 1 - - try: - full_page = await self._get(f"https://api.notion.com/v1/pages/{page_id}") - - breadcrumbs = await self._build_page_breadcrumbs(full_page) - - page_entity, file_entities = await self._create_comprehensive_page_entity( - full_page, breadcrumbs - ) - yield page_entity - - for file_entity in file_entities: - processed = await self._process_and_yield_file(file_entity, files) - if processed: - yield processed - - self._processed_pages.add(page_id) - - async for child_entity in self._process_child_databases(files): - yield child_entity - - except NotionSource.NotionAccessError as e: - self.logger.warning(f"Access issue processing standalone page {page_id}: {e}") - continue - except SourceAuthError: - raise - except Exception as e: - self.logger.warning(f"Error processing standalone page {page_id}: {e}") - continue + """The legacy formatter/crawler is replaced, not maintained as a second authority.""" + raise NotImplementedError("Notion requires canonical page capture") + yield # pragma: no cover diff --git a/backend/airweave/platform/sources/onedrive.py b/backend/airweave/platform/sources/onedrive.py index 6f61e9151d..f7bb68244d 100644 --- a/backend/airweave/platform/sources/onedrive.py +++ b/backend/airweave/platform/sources/onedrive.py @@ -33,13 +33,13 @@ from airweave.platform.entities._base import BaseEntity from airweave.platform.entities.onedrive import OneDriveDriveEntity, OneDriveDriveItemEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/onenote.py b/backend/airweave/platform/sources/onenote.py index 57c266581c..9e30f6bf96 100644 --- a/backend/airweave/platform/sources/onenote.py +++ b/backend/airweave/platform/sources/onenote.py @@ -36,12 +36,12 @@ OneNoteSectionEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/outlook_calendar.py b/backend/airweave/platform/sources/outlook_calendar.py index 9d294b35f8..f3d7fab243 100644 --- a/backend/airweave/platform/sources/outlook_calendar.py +++ b/backend/airweave/platform/sources/outlook_calendar.py @@ -15,9 +15,10 @@ from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderError from airweave.domains.browse_tree.types import NodeSelectionData from airweave.domains.sources.exceptions import SourceAuthError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.sources.token_providers.protocol import SourceAuthProvider from airweave.domains.storage import FileSkippedException from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor @@ -30,12 +31,13 @@ OutlookCalendarEventEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.composio_transport import ComposioProxyError +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.outlook_graph import OutlookBoundaryError, OutlookGraphClient from airweave.schemas.source_connection import AuthenticationMethod, OAuthType @@ -65,38 +67,38 @@ class OutlookCalendarSource(BaseSource): GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" + # Query/projection capability; only explicit fresh-source configuration opts + # an instance into the composed canonical page adapter below. + canonical_record_types = ("calendar", "event") + @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: OutlookCalendarConfig, ) -> "OutlookCalendarSource": """Create a new Outlook Calendar source instance.""" - return cls(auth=auth, logger=logger, http_client=http_client) + instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.graph = OutlookGraphClient( + auth, http_client, cls.short_name, config.expected_principal_id + ) + if config.capture_originals: + from airweave.platform.sources.outlook_calendar_capture import OutlookCalendarCapture + + instance._capture_page_source = await OutlookCalendarCapture.create( + graph=instance.graph, config=config + ) + else: + await instance.graph.verify_principal() + return instance # ------------------------------------------------------------------ # HTTP helpers # ------------------------------------------------------------------ - async def _authed_headers(self) -> Dict[str, str]: - """Build Authorization + Accept headers with a fresh token.""" - token = await self.auth.get_token() - return { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } - - async def _refresh_and_get_headers(self) -> Dict[str, str]: - """Force-refresh the token and return updated headers.""" - new_token = await self.auth.force_refresh() - return { - "Authorization": f"Bearer {new_token}", - "Accept": "application/json", - } - @retry( stop=stop_after_attempt(5), retry=retry_if_rate_limit_or_timeout, @@ -104,25 +106,8 @@ async def _refresh_and_get_headers(self) -> Dict[str, str]: reraise=True, ) async def _get(self, url: str, params: Optional[dict] = None) -> Any: - """Make an authenticated GET request to Microsoft Graph API.""" - self.logger.debug(f"Making authenticated GET request to: {url} with params: {params}") - - headers = await self._authed_headers() - response = await self.http_client.get(url, headers=headers, params=params) - - if response.status_code == 401 and self.auth.supports_refresh: - self.logger.warning( - f"Got 401 Unauthorized from Microsoft Graph API at {url}, refreshing token..." - ) - headers = await self._refresh_and_get_headers() - response = await self.http_client.get(url, headers=headers, params=params) - - raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, - ) - return response.json() + """Read only within the attested Microsoft Graph mailbox.""" + return await self.graph.get(url, params=params) # ------------------------------------------------------------------ # Entity generators @@ -141,7 +126,7 @@ async def _generate_calendar_entities( try: while url: - self.logger.debug(f"Fetching calendars from: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) calendars = data.get("value", []) self.logger.info(f"Retrieved {len(calendars)} calendars") @@ -184,7 +169,7 @@ async def _generate_calendar_entities( self.logger.info(f"Completed calendar generation. Total calendars: {calendar_count}") - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error generating calendar entities: {str(e)}") @@ -215,7 +200,7 @@ async def _generate_event_entities( try: while url: - self.logger.debug(f"Fetching events from: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url, params=params) events = data.get("value", []) self.logger.info(f"Retrieved {len(events)} events from calendar {calendar_name}") @@ -236,7 +221,12 @@ async def _generate_event_entities( event_data, cal_breadcrumb, files=files ): yield entity - except SourceAuthError: + except ( + SourceAuthError, + AuthProviderError, + ComposioProxyError, + OutlookBoundaryError, + ): raise except Exception as e: self.logger.warning(f"Error processing event {event_id}: {str(e)}") @@ -251,7 +241,7 @@ async def _generate_event_entities( f"Total events: {event_count}" ) - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error generating events for calendar {calendar_name}: {str(e)}") @@ -299,7 +289,7 @@ async def _process_event( self.logger.debug( f"Processed {attachment_count} attachments for event {event_subject}" ) - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error processing attachments for event {event_id}: {str(e)}") @@ -318,7 +308,7 @@ async def _process_event_attachments( try: while url: - self.logger.debug(f"Making request to: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) attachments = data.get("value", []) self.logger.debug(f"Retrieved {len(attachments)} attachments for event {event_id}") @@ -340,7 +330,7 @@ async def _process_event_attachments( if url: self.logger.debug("Following pagination link") - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error processing attachments for event {event_id}: {str(e)}") @@ -413,7 +403,12 @@ async def _process_single_attachment( # noqa: C901 self.logger.debug(f"Skipping attachment {attachment_name}: {e.reason}") return None - except SourceAuthError: + except ( + SourceAuthError, + AuthProviderError, + ComposioProxyError, + OutlookBoundaryError, + ): raise except Exception as e: @@ -422,7 +417,7 @@ async def _process_single_attachment( # noqa: C901 else: return file_entity - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error processing attachment {attachment_id}: {str(e)}") @@ -440,6 +435,7 @@ async def generate_entities( node_selections: list[NodeSelectionData] | None = None, ) -> AsyncGenerator[BaseEntity, None]: """Generate all Outlook Calendar entities: Calendars, Events and Attachments.""" + await self.graph.verify_principal() self.logger.info("Starting Outlook Calendar entity generation") entity_count = 0 @@ -462,7 +458,7 @@ async def generate_entities( ) yield event_entity - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error in entity generation: {str(e)}", exc_info=True) @@ -474,6 +470,7 @@ async def generate_entities( async def validate(self) -> None: """Validate credentials by pinging the calendars endpoint.""" + await self.graph.verify_principal() await self._get( f"{self.GRAPH_BASE_URL}/me/calendars", params={"$top": "1"}, diff --git a/backend/airweave/platform/sources/outlook_calendar_capture.py b/backend/airweave/platform/sources/outlook_calendar_capture.py new file mode 100644 index 0000000000..4c226e6571 --- /dev/null +++ b/backend/airweave/platform/sources/outlook_calendar_capture.py @@ -0,0 +1,364 @@ +"""Calendar-owned Graph originals using the shared resumable full-scan engine. + +Unbounded singles and masters precede bounded native expansion in the SAME event +scope. Previously captured occurrences remain retained while exact reads succeed; +the completed window describes expansion coverage, not a deletion boundary. +""" + +import hashlib +import json +from datetime import datetime, timezone +from urllib.parse import quote, unquote, urlsplit + +from pydantic import AwareDatetime, TypeAdapter, ValidationError + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import CapturePage, CapturePlan +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation, ScanState +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.storage.file_service import FileService +from airweave.platform.configs.config import CalendarOccurrenceWindow, OutlookCalendarConfig +from airweave.platform.sources.outlook_calendar_models import ( + OutlookCalendar, + OutlookCalendarContinuation, + OutlookCalendarEvent, + OutlookCalendarPage, + OutlookCalendarScopeContext, +) +from airweave.platform.sources.outlook_graph import OutlookBoundaryError, OutlookGraphClient + +BASE = "https://graph.microsoft.com/v1.0/me" + + +class OutlookCalendarCapture: + """Provider interpretation only; SQL owns progress, parent visibility and publication.""" + + canonical_record_types = ("calendar", "event") + canonical_container_parents = {"event": "calendar"} + + def __init__(self, graph: OutlookGraphClient, config: OutlookCalendarConfig) -> None: + """Use create to attest the principal before obtaining this composition.""" + self.graph = graph + self.config = config.model_copy(deep=True) + self.fingerprint = hashlib.sha256( + json.dumps( + {"version": 1, "id_type": "ImmutableId", "config": config.model_dump(mode="json")}, + sort_keys=True, + ).encode() + ).hexdigest() + + @classmethod + async def create( + cls, *, graph: OutlookGraphClient, config: OutlookCalendarConfig + ) -> "OutlookCalendarCapture": + """Require trusted mailbox identity; credential lifecycle remains in GraphClient.""" + instance = cls(graph, config) + if not config.expected_principal_id or ( + graph.expected_principal_id != config.expected_principal_id + ): + raise instance._error("Original Outlook calendars require a bound principal") + await graph.verify_principal() + instance._require_principal() + return instance + + @staticmethod + def _error(message: str) -> OutlookBoundaryError: + return OutlookBoundaryError(message, source_short_name="outlook_calendar") + + def _require_principal(self) -> None: + if not self.config.expected_principal_id or ( + self.graph.expected_principal_id != self.config.expected_principal_id + or self.graph.verified_principal_id != self.config.expected_principal_id + ): + raise self._error("Original Outlook calendar principal is not attested") + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Neither listing omissions nor ambiguous404 imply provider deletion.""" + self._require_principal() + return CycleConfiguration.from_source( + fingerprint=self.fingerprint, + record_types=self.canonical_record_types, + container_parents=self.canonical_container_parents, + completion_policies={ + "calendar": "discovery_with_validation", + "event": "discovery_with_validation", + }, + ) + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Resolve rolling bounds once; this first version does not claim incremental capture.""" + await self.graph.verify_principal() + self._require_principal() + return CapturePlan( + mode="full", + source_plan={"window": self.config.resolved_window().model_dump(mode="json")}, + ) + + def _scope(self, scope: CompletedScope, parent: SourceRecord | None) -> None: + root = scope.record_type == "calendar" + if root and scope.container_id is None and scope.parent is None and parent is None: + return + if ( + scope.record_type == "event" + and scope.parent is not None + and scope.parent.record_type == "calendar" + and scope.parent.container_id is None + and scope.container_id == scope.parent.native_id + and parent is not None + and parent.identity == scope.parent + ): + return + raise self._error("Outlook calendar scope does not match its native owner") + + async def prepare_scope( + self, + scope: CompletedScope, + cycle: CaptureCycle, + previous: ScanState | None, + *, + parent: SourceRecord | None, + force_full: bool, + ) -> ScopePlan: + """Both phases share one full sweep and one immutable expansion window.""" + self._scope(scope, parent) + if cycle.configuration != self.capture_cycle_configuration or cycle.mode != "full": + raise self._error("Outlook calendar cycle belongs to another configuration") + try: + window = CalendarOccurrenceWindow.model_validate(cycle.source_plan["window"]) + context = OutlookCalendarScopeContext( + fingerprint=self.fingerprint, calendar_id=scope.container_id, window=window + ) + return ScopePlan(request_context=context.model_dump(mode="json")) + except (ValidationError, KeyError): + raise self._error("Invalid Outlook calendar scope plan") from None + + def initial_scope_continuation( + self, scope: CompletedScope, cycle: CaptureCycle, plan: ScopePlan + ) -> ScanContinuation: + """Resume only the persisted scope request, never newly resolved wall-clock bounds.""" + self._require_principal() + try: + context = OutlookCalendarScopeContext.model_validate(plan.request_context) + except ValidationError: + raise self._error("Invalid Outlook calendar scope context") from None + if ( + plan.mode != "full" + or cycle.mode != "full" + or plan.starting_checkpoint is not None + or cycle.configuration != self.capture_cycle_configuration + or context.fingerprint != self.fingerprint + or context.calendar_id != scope.container_id + or context.window.model_dump(mode="json") != cycle.source_plan.get("window") + ): + raise self._error("Outlook calendar scope context changed") + return self._continuation( + OutlookCalendarContinuation( + context=context, phase="calendars" if scope.record_type == "calendar" else "events" + ) + ) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """One native calendar owns all event kinds and their eventual attachment children.""" + if ( + parent.identity.record_type != "calendar" + or parent.parent is not None + or record_type != "event" + ): + raise self._error("Invalid Outlook calendar child scope") + return CompletedScope( + record_type="event", container_id=parent.identity.native_id, parent=parent.identity + ) + + def _continuation(self, state: OutlookCalendarContinuation) -> ScanContinuation: + try: + return ScanContinuation(value=state.model_dump(mode="json")) + except ValidationError: + raise self._error( + "Outlook calendar continuation exceeds its bounded capacity" + ) from None + + def _collection_url(self, url: str, collection: str) -> None: + try: + actual, expected = urlsplit(url), urlsplit(collection) + if ( + actual.scheme == "https" + and actual.netloc == "graph.microsoft.com" + and unquote(actual.path) == unquote(expected.path) + and not actual.fragment + and not any(ord(char) < 32 for char in url) + ): + return + except ValueError: + pass + raise self._error("Outlook calendar continuation changed its native collection") + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Commit a complete bounded page with its next phase; late failures do not advance it.""" + self._require_principal() + self._scope(scope, parent) + try: + state = OutlookCalendarContinuation.model_validate(continuation.value) + except ValidationError: + raise self._error("Invalid Outlook calendar continuation") from None + root = scope.record_type == "calendar" + if ( + state.context.fingerprint != self.fingerprint + or state.context.calendar_id != scope.container_id + or (state.phase not in (("calendars",) if root else ("events", "calendarView"))) + ): + raise self._error("Outlook calendar continuation belongs to another scope") + collection = f"{BASE}/calendars" + params: dict[str, str | int] | None = {"$top": 100, "$select": "id"} + if not root: + collection += f"/{quote(scope.container_id or '', safe='')}/{state.phase}" + if state.phase == "calendarView": + # Graph's default response representation is UTC. Preserve that native JSON. + params = { + "$top": 100, + "startDateTime": state.context.window.start.isoformat(), + "endDateTime": state.context.window.end.isoformat(), + } + url = state.next_link or collection + self._collection_url(url, collection) + raw = await self.graph.get( + url, params=None if state.next_link else params, immutable_ids=True + ) + try: + page = OutlookCalendarPage.model_validate(raw) + except ValidationError: + raise self._error("Invalid Outlook calendar collection response") from None + ids = tuple(item.id for item in page.value) + if len(set(ids)) != len(ids): + raise self._error("Outlook calendar page repeated a native identity") + progress = self._advance(state, page, collection, url) + observations = [] + for native_id in ids: + if root: + observations.append(await self._calendar(native_id)) + else: + observations.append(await self._event(scope.parent, native_id)) + return CapturePage( + records=tuple(observations), + continuation=progress, + final=progress.value["phase"] == "done", + ) + + def _advance( + self, + state: OutlookCalendarContinuation, + page: OutlookCalendarPage, + collection: str, + url: str, + ) -> ScanContinuation: + """Validate bounded progress before fetching exact originals.""" + links = state.recent_links + if page.next_link: + self._collection_url(page.next_link, collection) + digest = hashlib.sha256(page.next_link.encode()).hexdigest() + if page.next_link == url or digest in links: + raise self._error("Outlook calendar pagination repeated a recent link") + links = (*links, digest)[-128:] + phase = ( + state.phase + if page.next_link + else ("calendarView" if state.phase == "events" else "done") + ) + return self._continuation( + state.model_copy( + update={ + "next_link": page.next_link, + "recent_links": links if page.next_link else (), + "phase": phase, + } + ) + ) + + async def _calendar(self, native_id: str) -> CaptureRecord: + raw = await self.graph.get( + f"{BASE}/calendars/{quote(native_id, safe='')}", immutable_ids=True + ) + try: + calendar = OutlookCalendar.model_validate(raw) + except ValidationError: + raise self._error("Invalid Outlook calendar metadata") from None + if calendar.id != native_id: + raise self._error("Outlook calendar identity changed during capture") + return CaptureRecord( + identity=RecordIdentity(record_type="calendar", native_id=native_id), + payload=raw, + observed_at=datetime.now(timezone.utc), + descendant_visibility_fields=("canViewPrivateItems",), + ) + + async def _event(self, parent: RecordIdentity | None, native_id: str) -> CaptureRecord: + if parent is None: + raise self._error("Outlook event original requires its calendar owner") + calendar_url = f"{BASE}/calendars/{quote(parent.native_id, safe='')}" + url = f"{calendar_url}/events/{quote(native_id, safe='')}" + raw = await self.graph.get(url, immutable_ids=True) + try: + event = OutlookCalendarEvent.model_validate(raw) + created = ( + TypeAdapter(AwareDatetime).validate_python(event.createdDateTime) + if event.createdDateTime + else None + ) + updated = ( + TypeAdapter(AwareDatetime).validate_python(event.lastModifiedDateTime) + if event.lastModifiedDateTime + else None + ) + except ValidationError: + raise self._error("Invalid Outlook event metadata") from None + if event.id != native_id: + raise self._error("Outlook event identity changed during capture") + return CaptureRecord( + identity=RecordIdentity( + record_type="event", native_id=native_id, container_id=parent.native_id + ), + parent=parent, + payload=raw, + observed_at=datetime.now(timezone.utc), + source_created_at=created, + source_updated_at=updated, + # Native attachments and selected-only master cancellation metadata are not + # retained yet. hasAttachments=false must not certify full content capture. + completeness="partial", + ) + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Historical instances remain originals; an ambiguous404 fails before reconciliation.""" + self._require_principal() + if ( + record.identity.record_type == "calendar" + and record.parent is None + and record.identity.container_id is None + ): + return await self._calendar(record.identity.native_id) + if ( + record.identity.record_type == "event" + and record.parent is not None + and record.parent.record_type == "calendar" + and record.parent.container_id is None + and record.identity.container_id == record.parent.native_id + ): + return await self._event(record.parent, record.identity.native_id) + raise self._error("Invalid Outlook exact-read identity") + + async def confirm_absent(self, record: SourceRecord) -> None: + """No arbitrary Graph failure establishes authoritative native absence.""" + raise self._error("Outlook calendar omissions require exact current observation") diff --git a/backend/airweave/platform/sources/outlook_calendar_models.py b/backend/airweave/platform/sources/outlook_calendar_models.py new file mode 100644 index 0000000000..dfb481edb2 --- /dev/null +++ b/backend/airweave/platform/sources/outlook_calendar_models.py @@ -0,0 +1,100 @@ +"""Native Outlook calendar boundaries; originals are retained without model dumping.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from airweave.platform.configs.config import CalendarOccurrenceWindow +from airweave.platform.sources.outlook_mail_models import OutlookBody, OutlookRecipient + + +class OutlookCalendar(BaseModel): + """Calendar identity and permission metadata, not a Google calendar representation.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(min_length=1) + name: str | None = None + owner: dict[str, JsonValue] | None = None + canEdit: bool | None = None + canViewPrivateItems: bool | None = None + isDefaultCalendar: bool | None = None + + +class OutlookDateTimeZone(BaseModel): + """Graph wall time plus native zone, including Windows or custom zone names.""" + + model_config = ConfigDict(extra="ignore", strict=True) + dateTime: str = Field(min_length=1) + timeZone: str = Field(min_length=1) + + +class OutlookCalendarEvent(BaseModel): + """The native event kind distinguishes masters, singles and expanded instances.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(min_length=1) + changeKey: str = Field(min_length=1) + type: Literal["singleInstance", "seriesMaster", "occurrence", "exception"] + start: OutlookDateTimeZone + end: OutlookDateTimeZone + isAllDay: bool + isCancelled: bool + hasAttachments: bool + subject: str | None = None + body: OutlookBody | None = None + bodyPreview: str | None = None + organizer: OutlookRecipient | None = None + attendees: list[dict[str, JsonValue]] = Field(default_factory=list) + seriesMasterId: str | None = None + originalStart: str | None = None + originalStartTimeZone: str | None = None + originalEndTimeZone: str | None = None + recurrence: dict[str, JsonValue] | None = None + cancelledOccurrences: list[str] | None = None + iCalUId: str | None = None + webLink: str | None = None + createdDateTime: str | None = None + lastModifiedDateTime: str | None = None + + +class OutlookCalendarID(BaseModel): + """A collection item is discovery evidence, not the retained exact original.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(min_length=1) + removed: JsonValue = Field(default=None, alias="@removed") + + @model_validator(mode="after") + def full_item(self): + """These full-list endpoints have no delta tombstone contract.""" + if "removed" in self.model_fields_set: + raise ValueError("Unexpected removed item in a full calendar listing") + return self + + +class OutlookCalendarPage(BaseModel): + """An absent array or unexpected delta removal is never an empty successful page.""" + + model_config = ConfigDict(extra="ignore", strict=True) + value: list[OutlookCalendarID] = Field(max_length=500) + next_link: str | None = Field(default=None, alias="@odata.nextLink", min_length=1) + + +class OutlookCalendarScopeContext(BaseModel): + """Immutable per-scope request authority, persisted by the shared page engine.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[1] = 1 + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + calendar_id: str | None = Field(default=None, min_length=1) + window: CalendarOccurrenceWindow + + +class OutlookCalendarContinuation(BaseModel): + """Only one collection's opaque continuation, never the expanding original archive.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + context: OutlookCalendarScopeContext + phase: Literal["calendars", "events", "calendarView", "done"] + next_link: str | None = Field(default=None, min_length=1) + recent_links: tuple[str, ...] = Field(default=(), max_length=128) diff --git a/backend/airweave/platform/sources/outlook_graph.py b/backend/airweave/platform/sources/outlook_graph.py new file mode 100644 index 0000000000..a24091ca51 --- /dev/null +++ b/backend/airweave/platform/sources/outlook_graph.py @@ -0,0 +1,232 @@ +"""Outlook's native mailbox boundary over the existing managed/direct transport.""" + +from contextlib import aclosing +from urllib.parse import unquote, urlsplit + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError + +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + SourceAuthProvider, + authorization_headers, +) +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.composio_transport import ComposioProxyError +from airweave.platform.sources.http_helpers import raise_for_status + + +class OutlookBoundaryError(SourceError): + """Unsafe mailbox scope or invalid principal evidence; never a skipped object.""" + + +class OutlookDeltaExpiredError(SourceError): + """Documented native delta expiration, without retaining private error bodies.""" + + +class GraphError(BaseModel): + """Only the native error code is interpreted; messages are never exposed.""" + + model_config = ConfigDict(extra="ignore", strict=True) + code: str + + +class GraphErrorEnvelope(BaseModel): + """Structured expiry evidence, distinct from transport and authentication errors.""" + + model_config = ConfigDict(extra="ignore", strict=True) + error: GraphError + + +class GraphPrincipal(BaseModel): + """Only the native, case-sensitive principal ID establishes mailbox identity.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + id: str = Field(min_length=1, pattern=r"^\S+$") + + +class OutlookGraphClient: + """Validate native scope and identity; credential custody stays in the transport.""" + + def __init__( + self, + auth: SourceAuthProvider, + http_client: AirweaveHttpClient, + source_name: str, + expected_principal_id: str | None, + ) -> None: + """Bind the expected principal supplied by the trusted connection owner.""" + self.auth = auth + self.http_client = http_client + self.source_name = source_name + self.expected_principal_id = expected_principal_id + self.verified_principal_id: str | None = None + if isinstance(auth, ManagedAuthProvider) and expected_principal_id is None: + raise self._boundary_error("Managed Outlook requires a trusted expected principal ID") + + def _boundary_error(self, message: str) -> OutlookBoundaryError: + return OutlookBoundaryError( + message, + source_short_name=self.source_name, + ) + + def _validate_url(self, url: str) -> None: + try: + parsed = urlsplit(url) + path = unquote(parsed.path) + valid = ( + parsed.scheme == "https" + and parsed.netloc == "graph.microsoft.com" + and not parsed.fragment + and (path == "/v1.0/me" or path.startswith("/v1.0/me/")) + and "\\" not in path + and not any(segment in (".", "..") for segment in path.split("/")) + and "%" not in path + and not any(ord(char) < 32 for char in url) + ) + except ValueError: + valid = False + if not valid: + raise self._boundary_error("Microsoft Graph URL is outside the bound mailbox") + + async def _request( + self, url: str, params: dict | None = None, *, immutable_ids: bool = False + ) -> httpx.Response: + self._validate_url(url) + headers = await authorization_headers(self.auth) + # Derived folder types remain explicit even when the request selects only id. + headers["Accept"] = "application/json;odata.metadata=minimal" + if immutable_ids: + headers["Prefer"] = 'IdType="ImmutableId"' + try: + return await self.http_client.get( + url, headers=headers, params=params, follow_redirects=False + ) + except ComposioProxyError: + raise + except httpx.TimeoutException: + raise httpx.TimeoutException("Microsoft Graph request timed out") from None + except httpx.RequestError: + raise httpx.RequestError("Microsoft Graph request failed") from None + + def _check_response(self, response: httpx.Response) -> None: + # Native errors may echo tokens or private data. Keep status/retry timing only. + safe = httpx.Response( + response.status_code, + headers={"Retry-After": response.headers["Retry-After"]} + if "Retry-After" in response.headers + else {}, + json={}, + ) + raise_for_status( + safe, source_short_name=self.source_name, token_provider_kind=self.auth.provider_kind + ) + + async def verify_principal(self) -> None: + """Re-attest on validation/enumeration; a failed check invalidates old evidence.""" + self.verified_principal_id = None + if self.expected_principal_id is None: + return # Legacy direct accounts need not acquire a new User.Read permission. + response = await self._request("https://graph.microsoft.com/v1.0/me", {"$select": "id"}) + if response.status_code == 401 and self.auth.supports_refresh: + await authorization_headers(self.auth, refresh=True) + response = await self._request("https://graph.microsoft.com/v1.0/me", {"$select": "id"}) + self._check_response(response) + try: + principal = GraphPrincipal.model_validate(response.json()) + except (ValidationError, ValueError): + raise self._boundary_error( + "Microsoft Graph returned an invalid principal identity" + ) from None + if principal.id != self.expected_principal_id: + raise self._boundary_error( + "Microsoft Graph principal does not match the expected identity" + ) + self.verified_principal_id = principal.id + + async def get( + self, url: str, params: dict | None = None, *, immutable_ids: bool = False + ) -> dict: + """Preserve legacy IDs while rejecting requests without current attestation.""" + if self.expected_principal_id is not None and ( + self.verified_principal_id != self.expected_principal_id + ): + raise self._boundary_error("Microsoft Graph principal must be attested before reading") + response = await self._request(url, params, immutable_ids=immutable_ids) + if response.status_code == 401: + self.verified_principal_id = None + if self.auth.supports_refresh: + await authorization_headers(self.auth, refresh=True) + await self.verify_principal() + response = await self._request(url, params, immutable_ids=immutable_ids) + if response.status_code == 401: + self.verified_principal_id = None + if urlsplit(url).path.lower().endswith("/messages/delta") and response.status_code in ( + 400, + 404, + 410, + ): + try: + error = GraphErrorEnvelope.model_validate(response.json()) + except ValueError: + error = None + if error is not None and error.error.code == "syncStateNotFound": + raise OutlookDeltaExpiredError( + "Microsoft Graph delta state expired", source_short_name=self.source_name + ) + self._check_response(response) + try: + data = TypeAdapter(dict[str, JsonValue]).validate_python(response.json()) + if "error" in data: + raise self._boundary_error("Microsoft Graph returned an error envelope") + return data + except ValueError: + raise self._boundary_error( + "Microsoft Graph returned an invalid JSON response" + ) from None + + async def mime_bytes(self, url: str, *, max_bytes: int) -> bytes: + """Bound original MIME reads; canonical requests alone opt into immutable IDs.""" + if self.expected_principal_id is None or ( + self.verified_principal_id != self.expected_principal_id + ): + raise self._boundary_error("Canonical MIME requires an attested principal") + self._validate_url(url) + for attempt in range(2): + headers = await authorization_headers(self.auth) + headers["Prefer"] = 'IdType="ImmutableId"' + try: + async with self.http_client.stream( + "GET", url, headers=headers, follow_redirects=False + ) as response: + if response.status_code == 401: + self.verified_principal_id = None + if not attempt and self.auth.supports_refresh: + await authorization_headers(self.auth, refresh=True) + await self.verify_principal() + continue + self._check_response(response) + return await self._read_mime_response(response, max_bytes) + except ComposioProxyError: + raise + except httpx.TimeoutException: + raise httpx.TimeoutException("Microsoft Graph MIME request timed out") from None + except httpx.RequestError: + raise httpx.RequestError("Microsoft Graph MIME request failed") from None + raise self._boundary_error("Microsoft Graph MIME authentication failed") + + async def _read_mime_response(self, response: httpx.Response, max_bytes: int) -> bytes: + media_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if media_type not in ("message/rfc822", "text/plain", "application/octet-stream"): + raise self._boundary_error("Microsoft Graph returned a non-MIME response") + content = bytearray() + async with aclosing(response.aiter_bytes()) as chunks: + async for chunk in chunks: + if len(content) + len(chunk) > max_bytes: + raise FileSkippedException("Original MIME exceeds byte limit", "message") + content.extend(chunk) + if not content: + raise self._boundary_error("Microsoft Graph returned empty MIME") + return bytes(content) diff --git a/backend/airweave/platform/sources/outlook_mail.py b/backend/airweave/platform/sources/outlook_mail.py index cdc3ce6ccc..e50b565d73 100644 --- a/backend/airweave/platform/sources/outlook_mail.py +++ b/backend/airweave/platform/sources/outlook_mail.py @@ -16,9 +16,10 @@ from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderError from airweave.domains.browse_tree.types import NodeSelectionData from airweave.domains.sources.exceptions import SourceAuthError, SourceEntityNotFoundError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.sources.token_providers.protocol import SourceAuthProvider from airweave.domains.storage import FileSkippedException from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor @@ -34,12 +35,13 @@ OutlookMessageEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.composio_transport import ComposioProxyError +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.outlook_graph import OutlookBoundaryError, OutlookGraphClient from airweave.platform.utils.filename_utils import safe_filename from airweave.schemas.source_connection import AuthenticationMethod, OAuthType @@ -71,17 +73,32 @@ class OutlookMailSource(BaseSource): GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" + # Offline projection/read capability. This does not opt legacy source instances + # into CanonicalPageSource; immutable mailbox capture is composed separately. + canonical_record_types = ("message",) + @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: OutlookMailConfig, ) -> "OutlookMailSource": """Create a new Outlook Mail source instance.""" instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.graph = OutlookGraphClient( + auth, http_client, cls.short_name, config.expected_principal_id + ) + if config.capture_originals: + from airweave.platform.sources.outlook_mail_capture import OutlookMailCapture + + instance._capture_page_source = await OutlookMailCapture.create( + graph=instance.graph, config=config + ) + else: + await instance.graph.verify_principal() instance.after_date = config.after_date instance.included_folders = config.included_folders instance.excluded_folders = config.excluded_folders @@ -91,16 +108,6 @@ async def create( # HTTP helpers # ------------------------------------------------------------------ - async def _authed_headers(self) -> Dict[str, str]: - """Build Authorization header with a fresh token.""" - token = await self.auth.get_token() - return {"Authorization": f"Bearer {token}"} - - async def _refresh_and_get_headers(self) -> Dict[str, str]: - """Force-refresh the token and return updated headers.""" - new_token = await self.auth.force_refresh() - return {"Authorization": f"Bearer {new_token}"} - @retry( stop=stop_after_attempt(5), retry=retry_if_rate_limit_or_timeout, @@ -108,27 +115,8 @@ async def _refresh_and_get_headers(self) -> Dict[str, str]: reraise=True, ) async def _get(self, url: str, params: Optional[dict] = None) -> dict: - """Make an authenticated GET request to Microsoft Graph API.""" - self.logger.debug(f"Making authenticated GET request to: {url} with params: {params}") - - headers = await self._authed_headers() - response = await self.http_client.get(url, headers=headers, params=params) - - if response.status_code == 401 and self.auth.supports_refresh: - self.logger.warning( - f"Got 401 Unauthorized from Microsoft Graph API at {url}, refreshing token..." - ) - headers = await self._refresh_and_get_headers() - response = await self.http_client.get(url, headers=headers, params=params) - - raise_for_status( - response, - source_short_name=self.short_name, - token_provider_kind=self.auth.provider_kind, - ) - data = response.json() - self.logger.debug(f"Received response from {url} - Status: {response.status_code}") - return data + """Read only within the attested Microsoft Graph mailbox.""" + return await self.graph.get(url, params=params) # ------------------------------------------------------------------ # Cursor helpers @@ -234,7 +222,7 @@ async def _process_folder_messages( folder_entity, folder_breadcrumb, files=files ): yield entity - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -263,7 +251,7 @@ async def _process_child_folders( files=files, ): yield child_entity - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -278,7 +266,7 @@ async def _init_and_store_message_delta_for_folder( """Initialize the per-folder message delta link and store it in the cursor.""" try: delta_url = f"{self.GRAPH_BASE_URL}/me/mailFolders/{folder_entity.id}/messages/delta" - self.logger.debug(f"Calling delta endpoint: {delta_url}") + self.logger.debug("Reading Microsoft Graph page") delta_data = await self._get(delta_url) attempts = 0 @@ -310,7 +298,7 @@ async def _init_and_store_message_delta_for_folder( f"{folder_entity.display_name} while initializing delta." ) break - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -387,7 +375,7 @@ async def _generate_folder_entities( try: while url: - self.logger.debug(f"Making request to: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) folders = data.get("value", []) self.logger.debug(f"Retrieved {len(folders)} folders") @@ -400,10 +388,10 @@ async def _generate_folder_entities( next_link = data.get("@odata.nextLink") if next_link: - self.logger.debug(f"Following pagination link: {next_link}") + self.logger.debug("Reading Microsoft Graph page") url = next_link if next_link else None - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error fetching folders: {str(e)}") @@ -477,7 +465,12 @@ async def _generate_message_entities( # noqa: C901 message_data, folder_entity.display_name, folder_breadcrumb, files=files ): yield entity - except SourceAuthError: + except ( + SourceAuthError, + AuthProviderError, + ComposioProxyError, + OutlookBoundaryError, + ): raise except Exception as e: self.logger.warning(f"Error processing message {message_id}: {str(e)}") @@ -493,7 +486,7 @@ async def _generate_message_entities( # noqa: C901 ) break - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -561,7 +554,7 @@ async def _process_message( self.logger.debug( f"Processed {attachment_count} attachments for message {message_id}" ) - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -667,7 +660,7 @@ async def _process_single_attachment( # noqa: C901 self.logger.debug(f"Skipping attachment {attachment_name}: {e.reason}") return None - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: @@ -688,7 +681,7 @@ async def _process_attachments( try: while url: - self.logger.debug(f"Making request to: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) attachments = data.get("value", []) self.logger.debug( @@ -712,7 +705,7 @@ async def _process_attachments( if url: self.logger.debug("Following pagination link") - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error processing attachments for message {message_id}: {str(e)}") @@ -736,7 +729,7 @@ async def _process_delta_changes( url = f"{self.GRAPH_BASE_URL}/me/mailFolders/{folder_id}/messages/delta" params = {"$deltatoken": delta_token} while url: - self.logger.debug(f"Fetching delta changes from: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url, params=params) params = None @@ -769,7 +762,7 @@ async def _process_delta_changes( if url: self.logger.debug("Following delta pagination") - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -814,7 +807,7 @@ async def _initialize_folders_delta_link(self, cursor: SyncCursor | None = None) """Initialize and store the delta link for the mailFolders collection.""" try: init_url = f"{self.GRAPH_BASE_URL}/me/mailFolders/delta" - self.logger.debug(f"Initializing folders delta link via: {init_url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(init_url) safety_counter = 0 @@ -834,7 +827,7 @@ async def _initialize_folders_delta_link(self, cursor: SyncCursor | None = None) else: self.logger.warning("No deltaLink or nextLink while initializing folders delta") break - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Failed to initialize folders delta link: {e}") @@ -864,7 +857,7 @@ async def _process_folders_delta_changes( cursor.update(folders_delta_link=new_delta_link) self.logger.debug("Updated folders_delta_link for next incremental run") - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error processing folders delta changes: {e}") @@ -873,7 +866,7 @@ async def _iterate_delta_pages(self, start_url: str) -> AsyncGenerator[Dict[str, """Iterate delta/next pages starting from a delta or nextLink URL.""" url = start_url while url: - self.logger.debug(f"Fetching folders delta changes from: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) yield data url = data.get("@odata.nextLink") @@ -1006,7 +999,7 @@ async def _emit_folder_add_or_update( # noqa: C901 msg_delta_data = await self._get(next_link) else: break - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Failed to initialize message delta for folder {folder_id}: {e}") @@ -1025,7 +1018,7 @@ async def _process_delta_changes_url( # noqa: C901 try: url = delta_url while url: - self.logger.debug(f"Fetching delta changes from: {url}") + self.logger.debug("Reading Microsoft Graph page") data = await self._get(url) changes = data.get("value", []) @@ -1079,7 +1072,7 @@ async def _process_delta_changes_url( # noqa: C901 ) break - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning( @@ -1135,6 +1128,7 @@ async def generate_entities( # noqa: C901 Supports both full sync (first run) and incremental sync (subsequent runs) using Microsoft Graph delta API. """ + await self.graph.verify_principal() self.logger.debug("===== STARTING OUTLOOK MAIL ENTITY GENERATION =====") entity_count = 0 @@ -1178,7 +1172,7 @@ async def generate_entities( # noqa: C901 ) yield entity - except SourceAuthError: + except (SourceAuthError, AuthProviderError, ComposioProxyError, OutlookBoundaryError): raise except Exception as e: self.logger.warning(f"Error in entity generation: {str(e)}", exc_info=True) @@ -1190,6 +1184,7 @@ async def generate_entities( # noqa: C901 async def validate(self) -> None: """Validate credentials by pinging the mailFolders endpoint.""" + await self.graph.verify_principal() await self._get( f"{self.GRAPH_BASE_URL}/me/mailFolders", params={"$top": "1"}, diff --git a/backend/airweave/platform/sources/outlook_mail_capture.py b/backend/airweave/platform/sources/outlook_mail_capture.py new file mode 100644 index 0000000000..f6b0b27cd9 --- /dev/null +++ b/backend/airweave/platform/sources/outlook_mail_capture.py @@ -0,0 +1,479 @@ +"""Mailbox-owned Outlook originals with bounded per-folder delta progress.""" + +import hashlib +import json +from datetime import datetime, timezone +from urllib.parse import quote, unquote, urlsplit + +from pydantic import AwareDatetime, TypeAdapter, ValidationError + +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CycleConfiguration, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidCaptureCheckpoint, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceGoneError +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.configs.config import OutlookMailConfig +from airweave.platform.sources.outlook_graph import ( + OutlookBoundaryError, + OutlookDeltaExpiredError, + OutlookGraphClient, +) +from airweave.platform.sources.outlook_mail_models import ( + OutlookDeltaPage, + OutlookFolder, + OutlookFolderPage, + OutlookMailCheckpoint, + OutlookMailContinuation, + OutlookMessage, + OutlookMessageID, +) + +BASE = "https://graph.microsoft.com/v1.0/me" + + +class OutlookMailCapture: + """One root scan uses shared page commits, blobs and exact omission validation. + + MIME-backed schema1 originals are partial until native attachment inventory is + qualified. Folder removals are signals, never proof of mailbox deletion. + """ + + canonical_record_types = ("message",) + canonical_container_parents: dict[str, str | tuple[str | None, ...]] = {} + + def __init__( + self, + graph: OutlookGraphClient, + config: OutlookMailConfig, + included_ids: frozenset[str], + excluded_ids: frozenset[str], + ) -> None: + """Construct through create so the native principal and folder aliases are resolved.""" + self.graph = graph + self.config = config.model_copy(deep=True) + self.included_ids = included_ids + self.excluded_ids = excluded_ids + self.after = ( + datetime.strptime(config.after_date, "%Y/%m/%d").replace(tzinfo=timezone.utc) + if config.after_date + else None + ) + self.fingerprint = hashlib.sha256( + json.dumps( + { + "version": 2, + "id_type": "ImmutableId", + "config": self.config.model_dump(mode="json"), + "included_ids": sorted(included_ids), + "excluded_ids": sorted(excluded_ids), + }, + sort_keys=True, + ).encode() + ).hexdigest() + + @classmethod + async def create( + cls, *, graph: OutlookGraphClient, config: OutlookMailConfig + ) -> "OutlookMailCapture": + """Require a trusted identity and resolve native folder aliases, never display names.""" + if not config.expected_principal_id or ( + graph.expected_principal_id != config.expected_principal_id + ): + raise OutlookBoundaryError( + "Canonical Outlook requires a bound principal", source_short_name="outlook_mail" + ) + await graph.verify_principal() + folders = {} + for alias in dict.fromkeys((*config.included_folders, *config.excluded_folders)): + raw = await graph.get( + f"{BASE}/mailFolders/{quote(alias, safe='')}", params={"$select": "id"} + ) + try: + folders[alias] = OutlookMessageID.model_validate(raw).id + except ValidationError: + raise OutlookBoundaryError( + "Invalid Outlook folder identity", source_short_name="outlook_mail" + ) from None + return cls( + graph, + config, + frozenset(folders[x] for x in config.included_folders), + frozenset(folders[x] for x in config.excluded_folders), + ) + + def _require_principal(self) -> None: + if not self.config.expected_principal_id or ( + self.graph.verified_principal_id != self.config.expected_principal_id + or self.graph.expected_principal_id != self.config.expected_principal_id + ): + raise OutlookBoundaryError( + "Canonical Outlook principal is not attested", source_short_name="outlook_mail" + ) + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Observed enumeration is not a snapshot; omissions require exact validation.""" + self._require_principal() + return CycleConfiguration.from_source( + fingerprint=self.fingerprint, + record_types=self.canonical_record_types, + container_parents={}, + completion_policies={"message": "discovery_with_validation"}, + ) + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Only a compatible promoted mailbox checkpoint authorizes changes mode.""" + await self.graph.verify_principal() + self._require_principal() + if ( + previous is None + or previous.configuration != self.capture_cycle_configuration + or previous.promoted_checkpoint is None + ): + # An explicit empty starting map opts into terminal checkpoint publication. + # It is not a completed native baseline and full mode never reuses its tokens. + return CapturePlan( + starting_checkpoint=ProviderCheckpoint( + value=OutlookMailCheckpoint(fingerprint=self.fingerprint).model_dump( + mode="json" + ) + ) + ) + checkpoint = previous.promoted_checkpoint.checkpoint + self._checkpoint(checkpoint) + return CapturePlan(mode="changes", starting_checkpoint=checkpoint) + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """Bind progress to the persisted principal, selection and immutable ID format.""" + self._require_principal() + if cycle.configuration != self.capture_cycle_configuration: + raise self._error("Outlook capture configuration changed") + links = {} + if cycle.mode == "changes": + if cycle.starting_checkpoint is None: + raise self._error("Outlook changes require a complete mailbox checkpoint") + links = self._checkpoint(cycle.starting_checkpoint).folder_links + elif cycle.mode != "full": + raise self._error("Unsupported Outlook capture mode") + return self._continuation( + OutlookMailContinuation( + fingerprint=self.fingerprint, mode=cycle.mode, folder_links=links + ) + ) + + @staticmethod + def _error(message: str) -> OutlookBoundaryError: + return OutlookBoundaryError(message, source_short_name="outlook_mail") + + def _checkpoint(self, checkpoint: ProviderCheckpoint) -> OutlookMailCheckpoint: + try: + state = OutlookMailCheckpoint.model_validate(checkpoint.value) + except ValidationError: + raise self._error("Invalid Outlook mailbox checkpoint") from None + if state.fingerprint != self.fingerprint: + raise self._error("Outlook checkpoint belongs to another capture scope") + for folder, link in state.folder_links.items(): + self._collection_url(link, self._delta_url(folder)) + return state + + def _continuation(self, state: OutlookMailContinuation) -> ScanContinuation: + try: + return ScanContinuation(value=state.model_dump(mode="json")) + except ValidationError: + raise self._error("Outlook continuation exceeds the 64 KiB capacity") from None + + def _state(self, continuation: ScanContinuation) -> OutlookMailContinuation: + try: + state = OutlookMailContinuation.model_validate(continuation.value) + except ValidationError: + raise self._error("Invalid Outlook capture continuation") from None + if state.fingerprint != self.fingerprint: + raise self._error("Outlook continuation belongs to another capture scope") + return state + + @staticmethod + def _delta_url(folder: str) -> str: + return f"{BASE}/mailFolders/{quote(folder, safe='')}/messages/delta" + + def _collection_url(self, url: str, expected: str) -> None: + """Opaque queries remain unchanged, but cannot redirect to another collection.""" + self.graph._validate_url(url) + path = unquote(urlsplit(url).path).replace("/mailfolders", "/mailFolders") + expected_path = unquote(urlsplit(expected).path) + allowed = {expected_path} + prefix = "/v1.0/me/mailFolders/" + if expected_path.startswith(prefix): + folder, suffix = expected_path[len(prefix) :].split("/", 1) + allowed.add(f"/v1.0/me/mailFolders('{folder}')/{suffix}") + if path.rstrip("/") not in allowed: + raise self._error("Outlook continuation changed the native collection") + + def _physical_folder(self, folder: OutlookFolder) -> bool: + """Virtual views duplicate physical messages but may contain real child folders.""" + if folder.odata_type == "#microsoft.graph.mailSearchFolder": + if folder.id in self.included_ids or folder.id in self.excluded_ids: + raise self._error("Outlook search-folder selections are not supported") + return False + if folder.odata_type != "#microsoft.graph.mailFolder": + raise self._error("Outlook unknown folder type is not qualified for delta capture") + return True + + async def _topology_page(self, state: OutlookMailContinuation) -> OutlookMailContinuation: + """Enumerate each folder's hidden children; topology never owns message visibility.""" + folder = state.folders_to_visit[0] + collection = ( + f"{BASE}/mailFolders/{quote(folder, safe='')}/childFolders" + if folder + else f"{BASE}/mailFolders" + ) + url = state.next_link or collection + self._collection_url(url, collection) + raw = await self.graph.get( + url, + params=None + if state.next_link + else {"includeHiddenFolders": "true", "$select": "id", "$top": 25}, + immutable_ids=True, + ) + try: + page = OutlookFolderPage.model_validate(raw) + except ValidationError: + raise self._error("Invalid Outlook folder inventory") from None + discovered = list(state.discovered_folders) + physical = list(state.remaining_folders) + queue = list(state.folders_to_visit) + for item in page.value: + if self._physical_folder(item): + physical.append(item.id) + if item.id in discovered: + raise InvalidCaptureCheckpoint("Outlook folder topology repeated an identity") + discovered.append(item.id) + queue.append(item.id) + if page.next_link: + self._collection_url(page.next_link, collection) + if page.next_link == url: + raise self._error("Outlook topology continuation did not advance") + else: + queue.pop(0) + if queue: + return state.model_copy( + update={ + "folders_to_visit": tuple(queue), + "discovered_folders": tuple(discovered), + "remaining_folders": tuple(physical), + "next_link": page.next_link, + } + ) + if set(state.folder_links) - set(physical): + raise InvalidCaptureCheckpoint("Outlook folder disappeared; mailbox baseline required") + return state.model_copy( + update={ + "phase": "messages", + "folders_to_visit": (), + "discovered_folders": (), + "remaining_folders": tuple(sorted(physical)), + "next_link": None, + } + ) + + def _page( + self, state: OutlookMailContinuation, records: tuple[CaptureRecord, ...] = () + ) -> CapturePage: + continuation = self._continuation(state) + final = state.phase == "messages" and not state.remaining_folders + # Check the final encoding as well: checkpoints escape Unicode while cursors do not. + try: + checkpoint = ProviderCheckpoint( + value=OutlookMailCheckpoint( + fingerprint=self.fingerprint, folder_links=state.folder_links + ).model_dump(mode="json") + ) + except ValidationError: + raise self._error("Outlook checkpoint exceeds the 64 KiB capacity") from None + return CapturePage( + records=records, + continuation=continuation, + final=final, + provider_checkpoint=checkpoint if final else None, + ) + + async def _delta_page(self, state: OutlookMailContinuation) -> OutlookMailContinuation: + """Interpret one opaque folder delta page before exact hydration.""" + folder = state.remaining_folders[0] + collection = self._delta_url(folder) + url = state.next_link or state.folder_links.get(folder) or collection + self._collection_url(url, collection) + try: + raw = await self.graph.get( + url, + params={"$select": "id"} if url == collection else None, + immutable_ids=True, + ) + except (OutlookDeltaExpiredError, SourceGoneError): + raise InvalidCaptureCheckpoint( + "Outlook folder delta requires a fresh baseline" + ) from None + try: + page = OutlookDeltaPage.model_validate(raw) + except ValidationError: + raise self._error("Invalid Outlook message delta") from None + link = page.next_link or page.delta_link + self._collection_url(link, collection) + if page.next_link == url: + raise self._error("Outlook delta continuation did not advance") + links = dict(state.folder_links) + if page.delta_link: + links[folder] = page.delta_link + state = state.model_copy( + update={ + "folder_links": links, + "next_link": page.next_link, + "pending_ids": tuple(dict.fromkeys(item.id for item in page.value)), + } + ) + self._page(state) # Enforce working-state capacity before MIME/blob work. + return state + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """One topology page or exact message observation shares its atomic cursor commit.""" + self._require_principal() + if scope != CompletedScope(record_type="message") or parent is not None: + raise self._error("Outlook requires the mailbox message root") + state = self._state(continuation) + if state.phase == "topology": + return self._page(await self._topology_page(state)) + if not state.remaining_folders: + return self._page(state) + if not state.pending_ids: + state = await self._delta_page(state) + records = () + if state.pending_ids: + observation = await self.message(state.pending_ids[0], files=files) + # Full omissions exact-refresh known roots. Changes must carry scope withdrawals. + if observation.kind == "upsert" or state.mode == "changes": + records = (observation,) + state = state.model_copy(update={"pending_ids": state.pending_ids[1:]}) + if not state.pending_ids and state.next_link is None: + state = state.model_copy(update={"remaining_folders": state.remaining_folders[1:]}) + return self._page(state, records) + + @staticmethod + def _date(raw: str | None) -> datetime | None: + if raw is None: + return None + try: + return TypeAdapter(AwareDatetime).validate_python(raw) + except ValidationError: + raise OutlookBoundaryError( + "Invalid Outlook message timestamp", source_short_name="outlook_mail" + ) from None + + async def message(self, native_id: str, *, files: FileService) -> CaptureRecord: + """Exact current mailbox state decides membership; ambiguous404 never means deletion.""" + self._require_principal() + url = f"{BASE}/messages/{quote(native_id, safe='')}" + raw = await self.graph.get(url, immutable_ids=True) + try: + message = OutlookMessage.model_validate(raw) + except ValidationError: + raise OutlookBoundaryError( + "Invalid Outlook message metadata", source_short_name="outlook_mail" + ) from None + if message.id != native_id: + raise OutlookBoundaryError( + "Outlook message identity mismatch", source_short_name="outlook_mail" + ) + identity = RecordIdentity(record_type="message", native_id=native_id) + outside = message.parentFolderId in self.excluded_ids or ( + bool(self.included_ids) and message.parentFolderId not in self.included_ids + ) + received = self._date(message.receivedDateTime) + if self.after is not None and not outside: + if received is None: + raise OutlookBoundaryError( + "Outlook date selection requires a received timestamp", + source_short_name="outlook_mail", + ) + if received < self.after: + outside = True + if outside: + return CaptureRecord( + identity=identity, + payload={"id": native_id}, + kind="delete", + removal_reason="scope_removed", + observed_at=datetime.now(timezone.utc), + ) + blobs = () + try: + content = await self.graph.mime_bytes( + f"{url}/$value", max_bytes=files.MAX_FILE_SIZE_BYTES + ) + blob = await files.store_canonical_blob(content, media_type="message/rfc822") + blobs = (blob,) + except FileSkippedException: + pass # Explicit metadata-only original; transport/storage failures are not skips. + current = await self.graph.get( + url, params={"$select": "id,changeKey,parentFolderId"}, immutable_ids=True + ) + if any(current.get(key) != raw.get(key) for key in ("id", "changeKey", "parentFolderId")): + raise OutlookBoundaryError( + "Outlook message changed during original capture", source_short_name="outlook_mail" + ) + return CaptureRecord( + identity=identity, + payload=raw, + payload_schema_version=1, + observed_at=datetime.now(timezone.utc), + blobs=blobs, + completeness="partial" if blobs else "metadata_only", + source_created_at=self._date(message.createdDateTime), + source_updated_at=self._date(message.lastModifiedDateTime), + ) + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Exact omitted-root reads must succeed before completion.""" + if ( + record.identity.record_type != "message" + or record.identity.container_id is not None + or record.parent is not None + ): + raise OutlookBoundaryError( + "Outlook omission identity is outside mailbox scope", + source_short_name="outlook_mail", + ) + return await self.message(record.identity.native_id, files=files) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Messages own their MIME originals; there are no independently scanned children.""" + raise OutlookBoundaryError( + "Outlook message capture has no child scopes", source_short_name="outlook_mail" + ) + + async def confirm_absent(self, record: SourceRecord) -> None: + """The engine must use exact omission observations, never an absence assertion.""" + raise OutlookBoundaryError( + "Outlook requires exact omission validation", source_short_name="outlook_mail" + ) diff --git a/backend/airweave/platform/sources/outlook_mail_models.py b/backend/airweave/platform/sources/outlook_mail_models.py new file mode 100644 index 0000000000..05d2a88572 --- /dev/null +++ b/backend/airweave/platform/sources/outlook_mail_models.py @@ -0,0 +1,147 @@ +"""Typed Outlook native metadata and bounded full-capture continuation.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class OutlookAddress(BaseModel): + """Native email address; sender and from retain separate meanings.""" + + model_config = ConfigDict(extra="ignore", strict=True) + name: str | None = None + address: str | None = None + + +class OutlookRecipient(BaseModel): + """Graph recipient envelope.""" + + model_config = ConfigDict(extra="ignore", strict=True) + emailAddress: OutlookAddress + + +class OutlookBody(BaseModel): + """The full native body, distinct from bodyPreview.""" + + model_config = ConfigDict(extra="ignore", strict=True) + contentType: Literal["text", "html"] + content: str + + +class OutlookMessage(BaseModel): + """Validated native fields; capture retains the original JSON rather than a model dump.""" + + model_config = ConfigDict(extra="ignore", strict=True, populate_by_name=True) + id: str = Field(min_length=1) + changeKey: str = Field(min_length=1) + parentFolderId: str = Field(min_length=1) + subject: str | None = None + conversationId: str | None = None + internetMessageId: str | None = None + sender: OutlookRecipient | None = None + from_: OutlookRecipient | None = Field(default=None, alias="from") + replyTo: list[OutlookRecipient] = Field(default_factory=list) + toRecipients: list[OutlookRecipient] = Field(default_factory=list) + ccRecipients: list[OutlookRecipient] = Field(default_factory=list) + bccRecipients: list[OutlookRecipient] = Field(default_factory=list) + body: OutlookBody | None = None + bodyPreview: str | None = None + hasAttachments: bool = False + isDraft: bool = False + receivedDateTime: str | None = None + sentDateTime: str | None = None + createdDateTime: str | None = None + lastModifiedDateTime: str | None = None + webLink: str | None = None + + +class OutlookMessageID(BaseModel): + """A list result is discovery, never an authoritative current message payload.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(min_length=1) + + +class OutlookMessagePage(BaseModel): + """Explicit array required: missing metadata cannot become an empty successful page.""" + + model_config = ConfigDict(extra="ignore", strict=True) + value: list[OutlookMessageID] = Field(max_length=500) + next_link: str | None = Field(default=None, alias="@odata.nextLink", min_length=1) + + +class OutlookFolder(OutlookMessageID): + """Folder topology includes hidden folders and explicitly identifies search views.""" + + # OData minimal metadata omits the declared base type, but requires derived types. + odata_type: str = Field(default="#microsoft.graph.mailFolder", alias="@odata.type") + + +class OutlookFolderPage(BaseModel): + """One native topology page; missing arrays never mean an empty mailbox.""" + + model_config = ConfigDict(extra="ignore", strict=True) + value: list[OutlookFolder] = Field(max_length=500) + next_link: str | None = Field(default=None, alias="@odata.nextLink", min_length=1) + + +class OutlookDeltaPage(OutlookMessagePage): + """Both ordinary and removed IDs require exact mailbox hydration.""" + + delta_link: str | None = Field(default=None, alias="@odata.deltaLink", min_length=1) + + @model_validator(mode="after") + def terminal_or_next(self): + """Graph supplies exactly one continuation or terminal link.""" + if (self.next_link is None) == (self.delta_link is None): + raise ValueError("Delta page requires exactly one next or delta link") + return self + + +class OutlookMailCheckpoint(BaseModel): + """One complete mailbox round; byte capacity is enforced by ProviderCheckpoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[2] = 2 + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + folder_links: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def nonempty_links(self): + """An absent folder token cannot masquerade as a completed baseline.""" + if any(not folder or not link for folder, link in self.folder_links.items()): + raise ValueError("Folder checkpoints require nonempty identities and links") + return self + + +class OutlookMailContinuation(OutlookMailCheckpoint): + """Working topology and one pending delta page share the existing bounded cursor.""" + + mode: Literal["full", "changes"] = "full" + phase: Literal["topology", "messages"] = "topology" + # Empty identity represents only the mailbox topology root. + folders_to_visit: tuple[str, ...] = ("",) + discovered_folders: tuple[str, ...] = () + # Physical folders accumulated during topology, then consumed during message capture. + remaining_folders: tuple[str, ...] = () + pending_ids: tuple[str, ...] = Field(default=(), max_length=500) + next_link: str | None = Field(default=None, min_length=1) + + @model_validator(mode="after") + def coherent_progress(self): + """Malformed progress must not silently skip discovery or pending originals.""" + for values in (self.discovered_folders, self.remaining_folders, self.pending_ids): + if len(values) != len(set(values)) or any(not value for value in values): + raise ValueError("Outlook progress identities must be nonempty and unique") + if len(self.folders_to_visit) != len(set(self.folders_to_visit)): + raise ValueError("Outlook topology queue must be unique") + if self.phase == "topology": + if not self.folders_to_visit or self.pending_ids: + raise ValueError("Invalid topology phase progress") + if not set(self.remaining_folders).issubset(self.discovered_folders): + raise ValueError("Physical folders must have been discovered") + elif self.folders_to_visit or self.discovered_folders: + raise ValueError("Message phase cannot contain topology work") + elif not self.remaining_folders and (self.pending_ids or self.next_link): + raise ValueError("Completed message progress cannot contain pending work") + return self diff --git a/backend/airweave/platform/sources/pipedrive.py b/backend/airweave/platform/sources/pipedrive.py index a8903616d5..69ce0a9e0b 100644 --- a/backend/airweave/platform/sources/pipedrive.py +++ b/backend/airweave/platform/sources/pipedrive.py @@ -27,12 +27,12 @@ PipedriveProductEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod diff --git a/backend/airweave/platform/sources/powerpoint.py b/backend/airweave/platform/sources/powerpoint.py index de1074e8cf..f05acc8a76 100644 --- a/backend/airweave/platform/sources/powerpoint.py +++ b/backend/airweave/platform/sources/powerpoint.py @@ -35,12 +35,12 @@ from airweave.platform.entities._base import BaseEntity from airweave.platform.entities.powerpoint import PowerPointPresentationEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/records/calendar_pages.py b/backend/airweave/platform/sources/records/calendar_pages.py new file mode 100644 index 0000000000..18ddefb587 --- /dev/null +++ b/backend/airweave/platform/sources/records/calendar_pages.py @@ -0,0 +1,312 @@ +"""Native Calendar pages under the existing fenced mixed-scope capture engine.""" + +import hashlib +import json +from typing import Literal +from urllib.parse import quote + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from airweave.domains.entities.canonical.calendar import CalendarScopeContext +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CycleConfiguration, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidScopeCheckpoint, + RequiredScopeAccessLost, + ScopeAccessLost, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation, ScanState +from airweave.domains.entities.canonical.scope_execution import ScopePlan +from airweave.domains.sources.exceptions import SourceEntityNotFoundError, SourceGoneError +from airweave.platform.configs.config import CalendarOccurrenceWindow, GoogleCalendarConfig +from airweave.platform.sources.records.google_calendar import BASE, CalendarPage, GetJSON, record + + +class CalendarProgress(BaseModel): + """Bounded per-scope continuation; original payloads remain only in the store.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[1] = 1 + mode: Literal["full", "changes"] + context: CalendarScopeContext + sync_token: str | None = None + page_token: str | None = None + recent_tokens: tuple[str, ...] = Field(default=(), max_length=128) + selected_seen: tuple[str, ...] = Field(default=(), max_length=250) + selection_unavailable: bool = False + pages: int = Field(default=0, ge=0) + records: int = Field(default=0, ge=0) + bytes: int = Field(default=0, ge=0) + + def continuation(self) -> ScanContinuation: + """Validate the complete continuation against the engine byte budget.""" + return ScanContinuation(value=self.model_dump(mode="json")) + + +class CalendarPages: + """Acquisition only; SQL owns every page, scope publication and cycle transition.""" + + def __init__(self, get: GetJSON, config: GoogleCalendarConfig): + """Reuse the source authenticated native HTTP boundary.""" + self.get, self.config = get, config + + @property + def configuration(self) -> CycleConfiguration: + """Fingerprint native selection and capture semantics, not moving wall-clock bounds.""" + raw = {"version": 3, "config": self.config.model_dump(mode="json")} + return CycleConfiguration( + fingerprint=hashlib.sha256(json.dumps(raw, sort_keys=True).encode()).hexdigest(), + parents={ + "calendar": (None,), + "event": ("calendar",), + "event_occurrence": ("calendar",), + }, + scope_changes=("event",), + ) + + def prepare(self) -> CapturePlan: + """Resolve a rolling window once, before the durable cycle is created.""" + return CapturePlan( + mode="mixed", + source_plan={"window": self.config.resolved_window().model_dump(mode="json")}, + ) + + def scope_plan( + self, + scope: CompletedScope, + cycle: CaptureCycle, + previous: ScanState | None, + *, + parent: SourceRecord | None, + force_full: bool, + ) -> ScopePlan: + """Reuse native tokens only with compatible completed full and current publication.""" + params: dict[str, str | int] = {"maxResults": 250, "showHidden": "true"} + timezone = None + if scope.record_type != "calendar": + if ( + parent is None + or parent.identity != scope.parent + or scope.container_id != parent.identity.native_id + ): + raise ValueError("Calendar child requires its exact native owner") + params = {"maxResults": 250, "showDeleted": "true", "singleEvents": "false"} + if scope.record_type == "event_occurrence": + window = CalendarOccurrenceWindow.model_validate(cycle.source_plan["window"]) + timezone = parent.payload.get("timeZone") + if not isinstance(timezone, str) or not timezone: + raise ValueError("Calendar has no timezone for occurrence capture") + params.update( + singleEvents="true", + timeMin=window.start.isoformat(), + timeMax=window.end.isoformat(), + timeZone=timezone, + ) + elif scope.record_type != "event": + raise ValueError("Unknown Calendar scope") + context = CalendarScopeContext(parameters=params, timezone=timezone).model_dump(mode="json") + proposed = ScopePlan(request_context=context) + execution = previous.execution if previous else None + if ( + scope.record_type == "event" + and not force_full + and previous + and previous.fingerprint == cycle.configuration.fingerprint + and execution + and execution.last_full + and execution.published + and execution.last_full.policy == "exhaustive" + and execution.published.policy == "exhaustive" + and execution.last_full.matches(proposed, previous.parent_visibility_epoch) + and execution.published.matches(proposed, previous.parent_visibility_epoch) + and execution.published.checkpoint + ): + return proposed.model_copy( + update={"mode": "changes", "starting_checkpoint": execution.published.checkpoint} + ) + return proposed + + @staticmethod + def initial(plan: ScopePlan) -> ScanContinuation: + """Only the persisted scope plan determines native page parameters.""" + token = None + if plan.starting_checkpoint: + token = plan.starting_checkpoint.value.get("sync_token") + if not isinstance(token, str) or not token: + raise ValueError("Calendar scope checkpoint has no native sync token") + return CalendarProgress( + mode=plan.mode, + context=CalendarScopeContext.model_validate(plan.request_context), + sync_token=token, + ).continuation() + + async def page(self, scope: CompletedScope, continuation: ScanContinuation) -> CapturePage: + """Fetch bounded native data; no cursor or coverage writes happen here.""" + state = CalendarProgress.model_validate(continuation.value) + if state.selection_unavailable: + raise ValueError("A requested calendar is not accessible in current membership") + native = await self._fetch(scope, state) + root = scope.record_type == "calendar" + if len(native.items) > 250 or (native.nextPageToken and native.nextSyncToken): + raise ValueError("Calendar returned an invalid bounded page") + observations = tuple( + record(scope.record_type, item, scope.container_id) for item in native.items + ) + if len({item.identity for item in observations}) != len(observations): + raise ValueError("Calendar page repeated a native identity") + seen, unavailable = set(state.selected_seen), False + if root: + observations, seen, unavailable = await self._selected(native, state, observations) + token_history = state.recent_tokens + if native.nextPageToken: + digest = hashlib.sha256(native.nextPageToken.encode()).hexdigest() + if digest in token_history: + raise ValueError("Calendar returned a recent repeated page token") + token_history = (*token_history, digest)[-128:] + count = state.records + len(observations) + size = state.bytes + sum(len(json.dumps(item.payload).encode()) for item in observations) + pages = state.pages + 1 + if scope.record_type == "event_occurrence" and ( + count > 10000 or size > 20 * 1024 * 1024 or pages > 100 + ): + raise ValueError("Expanded Calendar capture exceeds bounded horizon budget") + checkpoint = None + if scope.record_type == "event" and not native.nextPageToken: + checkpoint = ProviderCheckpoint(value={"sync_token": native.completed_token()}) + progress = state.model_copy( + update={ + "page_token": native.nextPageToken, + "recent_tokens": token_history, + "selected_seen": tuple(sorted(seen)), + "selection_unavailable": unavailable, + "records": count, + "bytes": size, + "pages": pages, + } + ) + return CapturePage( + records=observations, + continuation=progress.continuation(), + final=not native.nextPageToken and not unavailable, + provider_checkpoint=checkpoint, + ) + + async def _fetch(self, scope: CompletedScope, state: CalendarProgress) -> CalendarPage: + params = dict(state.context.parameters) + if state.page_token: + params["pageToken"] = state.page_token + if state.sync_token: + params["syncToken"] = state.sync_token + root = scope.record_type == "calendar" + url = ( + f"{BASE}/users/me/calendarList" + if root + else f"{BASE}/calendars/{quote(scope.container_id or '', safe='')}/events" + ) + try: + native = ( + CalendarPage() + if root and self.config.calendar_ids == () + else CalendarPage.model_validate(await self.get(url, params=params)) + ) + except SourceGoneError as error: + if scope.record_type == "event" and state.mode == "changes": + raise InvalidScopeCheckpoint("Calendar incremental token expired") from error + raise + except SourceEntityNotFoundError as error: + if root: + raise + error_type = ( + RequiredScopeAccessLost if self.config.calendar_ids is not None else ScopeAccessLost + ) + raise error_type( + "Calendar is no longer accessible", removal_reason="scope_removed" + ) from error + return native + + async def _selected( + self, native: CalendarPage, state: CalendarProgress, observations: tuple[CaptureRecord, ...] + ): + seen = set(state.selected_seen) + if self.config.calendar_ids is not None: + selected = set(self.config.calendar_ids) + observations = tuple( + item for item in observations if item.identity.native_id in selected + ) + seen.update(item.identity.native_id for item in observations) + unavailable = ( + any(item.kind == "delete" for item in observations) + and self.config.calendar_ids is not None + ) + if not native.nextPageToken and self.config.calendar_ids is not None: + missing = set(self.config.calendar_ids) - seen + # Commit verified withdrawals before surfacing the configured-selection failure. + extra = [] + for native_id in sorted(missing): + await self.confirm_member_absent(native_id) + extra.append( + record("calendar", {"id": native_id, "deleted": True}).model_copy( + update={"removal_reason": "scope_removed"} + ) + ) + present = {item.identity.native_id for item in observations} + observations += tuple(item for item in extra if item.identity.native_id not in present) + unavailable = unavailable or bool(missing) + return observations, seen, unavailable + + async def confirm_member_absent(self, native_id: str) -> None: + """Only exact missing/deleted membership confirms a list omission.""" + if self.config.calendar_ids is not None and native_id not in self.config.calendar_ids: + return + try: + payload = await self.get(f"{BASE}/users/me/calendarList/{quote(native_id, safe='')}") + except SourceEntityNotFoundError: + return + if payload.get("id") != native_id: + raise ValueError("Calendar returned a different membership identity") + if payload.get("deleted") is not True and payload.get("accessRole") not in { + "none", + "freeBusyReader", + }: + raise ValueError("Calendar listing omitted accessible membership; retry capture") + + +class _NativeError(BaseModel): + model_config = ConfigDict(extra="ignore") + reason: str + location: str | None = None + locationType: str | None = None + + +class _NativeErrorBody(BaseModel): + model_config = ConfigDict(extra="ignore") + errors: tuple[_NativeError, ...] = () + + +class _NativeErrorEnvelope(BaseModel): + model_config = ConfigDict(extra="ignore") + error: _NativeErrorBody + + +def invalid_page_token(raw: dict) -> bool: + """Only explicit native pageToken parameter evidence permits resetting pagination.""" + try: + error = _NativeErrorEnvelope.model_validate(raw) + except ValidationError: + return False + return any( + item.location == "pageToken" + and item.locationType == "parameter" + and item.reason in {"badRequest", "invalidArgument", "invalid"} + for item in error.error.errors + ) diff --git a/backend/airweave/platform/sources/records/google_calendar.py b/backend/airweave/platform/sources/records/google_calendar.py new file mode 100644 index 0000000000..7651f6f31a --- /dev/null +++ b/backend/airweave/platform/sources/records/google_calendar.py @@ -0,0 +1,77 @@ +"""Native Calendar resource validation; page progress belongs to the canonical engine.""" + +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, TypeAdapter + +from airweave.domains.entities.canonical.calendar import is_cancelled_recurring_event +from airweave.domains.entities.canonical.requests import CaptureRecord, RecordIdentity + +GetJSON = Callable[..., Awaitable[dict]] +BASE = "https://www.googleapis.com/calendar/v3" + + +class CalendarPage(BaseModel): + """Validate pagination shape without stripping fields from original resources.""" + + model_config = ConfigDict(extra="ignore") + items: list[dict[str, JsonValue]] = Field(default_factory=list) + nextPageToken: str | None = None + nextSyncToken: str | None = None + + def completed_token(self) -> str: + """An exhausted event listing must supply its durable continuation boundary.""" + if not self.nextSyncToken: + raise ValueError("Calendar event enumeration ended without nextSyncToken") + return self.nextSyncToken + + +def record( + kind: str, payload: dict[str, JsonValue], calendar_id: str | None = None +) -> CaptureRecord: + """Preserve sparse cancellation identity as well as full live provider JSON.""" + native_id = payload.get("id") + if not isinstance(native_id, str) or not native_id: + raise ValueError("Calendar returned a resource without an ID") + recurring_cancellation = kind == "event" and is_cancelled_recurring_event(payload) + if ( + kind == "event" + and payload.get("status") == "cancelled" + and payload.get("recurringEventId") + and not recurring_cancellation + ): + raise ValueError("Cancelled recurring event lacks its original occurrence identity") + # Google requires clients to retain this exclusion for the lifetime of its series. + # Keep its native ID/container so cancellation and reinstatement update the same row. + unavailable_role = kind == "calendar" and payload.get("accessRole") in { + "none", + "freeBusyReader", + } + deleted = ( + unavailable_role + or (payload.get("status") == "cancelled" and not recurring_cancellation) + or payload.get("deleted") is True + ) + return CaptureRecord( + identity=RecordIdentity(record_type=kind, native_id=native_id, container_id=calendar_id), + payload=payload, + descendant_visibility_fields=("accessRole",) if kind == "calendar" else (), + kind="delete" if deleted else "upsert", + removal_reason=( + "access_revoked" + if unavailable_role + else "scope_removed" + if kind == "calendar" + else "provider_deleted" + ) + if deleted + else None, + source_created_at=TypeAdapter(AwareDatetime).validate_python(payload["created"]) + if payload.get("created") + else None, + source_updated_at=TypeAdapter(AwareDatetime).validate_python(payload["updated"]) + if payload.get("updated") + else None, + observed_at=datetime.now(timezone.utc), + ) diff --git a/backend/airweave/platform/sources/records/google_docs_content.py b/backend/airweave/platform/sources/records/google_docs_content.py new file mode 100644 index 0000000000..c627dcd399 --- /dev/null +++ b/backend/airweave/platform/sources/records/google_docs_content.py @@ -0,0 +1,136 @@ +"""Bounded native Docs acquisition under the owning Drive file transaction.""" + +import json +from urllib.parse import quote + +import httpx +from pydantic import JsonValue, TypeAdapter + +from airweave.domains.entities.canonical.requests import BlobReference, CaptureRecord +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.bounded_response import bounded_response_bytes +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.records.workspace_manifest import ( + MANIFEST_MIME, + DocsGap, + DocsState, + ExportState, + WorkspaceManifestV1, + canonical_json, + document_media_gaps, + parse_manifest, + validate_document, +) + +MAX_DOCS_BYTES = 32 * 1024 * 1024 +_DOCUMENT = TypeAdapter(dict[str, JsonValue]) + + +async def read_native_document( + file_id: str, *, client: AirweaveHttpClient, auth: SourceAuthProvider +) -> dict[str, JsonValue]: + """No field mask: preserve native tab structure, including unknown returned fields.""" + url = "https://docs.googleapis.com/v1/documents/" + quote(file_id, safe="") + parameters = { + "includeTabsContent": "true", + "suggestionsViewMode": "DEFAULT_FOR_CURRENT_ACCESS", + "commentsViewMode": "COMMENTS_VIEW_MODE_OMITTED", + } + for attempt in range(2): + headers = await authorization_headers(auth, refresh=attempt == 1) + headers["Accept-Encoding"] = "identity" + async with client.stream( + "GET", url, headers=headers, params=parameters, timeout=60.0 + ) as response: + if response.status_code == 401 and auth.supports_refresh and attempt == 0: + continue + # Classify errors without reading an unbounded/private error body. A large + # 401/429/5xx can never become a successful missing-representation gap. + if not response.is_success: + raise_for_status( + httpx.Response( + response.status_code, + headers=response.headers, + content=b"", + request=response.request, + ), + source_short_name="google_drive", + token_provider_kind=auth.provider_kind, + ) + if response.headers.get("content-encoding", "identity").lower() != "identity": + raise ValueError("Docs capture requires identity response encoding") + body = await bounded_response_bytes(response, MAX_DOCS_BYTES, label="Docs JSON") + document = _DOCUMENT.validate_python(json.loads(body)) + validate_document(document, file_id=file_id) + return document + raise ValueError("Docs authentication refresh did not complete") + + +async def capture_document_parts( + record: CaptureRecord, + *, + export: ExportState, + export_blob: BlobReference | None, + files: FileService, + client: AirweaveHttpClient, + auth: SourceAuthProvider, +) -> CaptureRecord: + """Stage every part then manifest; the caller still must check final Drive version.""" + parts = [export_blob] if export_blob is not None else [] + try: + document = await read_native_document(record.identity.native_id, client=client, auth=auth) + except FileSkippedException: + native = DocsState( + status="unavailable", + embedded_media="not_retained", + missing=(DocsGap(reason="read_size_limit"),), + ) + else: + blob = await files.store_canonical_blob( + canonical_json(document), media_type="application/json" + ) + parts.append(blob) + gaps = document_media_gaps(document) + native = DocsState( + status="complete", + document_blob=blob.sha256, + embedded_media="not_retained" if gaps else "retained", + missing=gaps, + ) + version = record.payload["version"] + manifest = WorkspaceManifestV1( + file_id=record.identity.native_id, drive_version=version, export=export, native=native + ) + content = canonical_json(manifest.model_dump(mode="json")) + manifest_blob = ( + await files.store_canonical_blob(content, media_type=MANIFEST_MIME) + ).model_copy(update={"role": "representation_manifest"}) + # Workspace-local identity is bytes, never descriptor order. Other providers keep MIME paths. + unique: dict[str, BlobReference] = {} + for part in parts: + previous = unique.get(part.sha256) + if previous is not None and (previous.key, previous.size_bytes) != ( + part.key, + part.size_bytes, + ): + raise ValueError("Workspace identical digest has conflicting storage descriptors") + if previous is not None and previous.media_type != part.media_type: + part = part.model_copy(update={"media_type": "application/octet-stream"}) + unique[part.sha256] = part + blobs = tuple(unique[key] for key in sorted(unique)) + (manifest_blob,) + parse_manifest(content, file_id=record.identity.native_id, drive_version=version, blobs=blobs) + complete = native.status == "complete" and not native.missing + return CaptureRecord.model_validate( + record.model_dump() + | { + "blobs": blobs, + "content_hash": manifest_blob.sha256, + "completeness": "complete" if complete else "partial", + } + ) diff --git a/backend/airweave/platform/sources/records/google_drive.py b/backend/airweave/platform/sources/records/google_drive.py new file mode 100644 index 0000000000..36bb28b45b --- /dev/null +++ b/backend/airweave/platform/sources/records/google_drive.py @@ -0,0 +1,33 @@ +"""Native Drive identity and original record mapping shared with body capture.""" + +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone + +from pydantic import JsonValue + +from airweave.domains.entities.canonical.requests import CaptureRecord, RecordIdentity +from airweave.platform.entities.google_drive import _parse_drive_dt + +BASE = "https://www.googleapis.com/drive/v3" +GetJSON = Callable[..., Awaitable[dict]] + + +def file_record(payload: dict[str, JsonValue], *, removed: bool = False) -> CaptureRecord: + """A Drive removal may mean lost access; do not invent permanent provider deletion.""" + native_id = payload.get("id") + if not isinstance(native_id, str) or not native_id: + raise ValueError("Drive resource has no file identity") + folder = payload.get("mimeType") in { + "application/vnd.google-apps.folder", + "application/vnd.google-apps.shortcut", + } + return CaptureRecord( + identity=RecordIdentity(record_type="file", native_id=native_id), + payload=payload, + kind="delete" if removed else "upsert", + removal_reason="scope_removed" if removed else None, + completeness="complete" if folder or removed else "metadata_only", + observed_at=datetime.now(timezone.utc), + source_created_at=_parse_drive_dt(payload.get("createdTime")), + source_updated_at=_parse_drive_dt(payload.get("modifiedTime")), + ) diff --git a/backend/airweave/platform/sources/records/google_drive_content.py b/backend/airweave/platform/sources/records/google_drive_content.py new file mode 100644 index 0000000000..58b7e2fdff --- /dev/null +++ b/backend/airweave/platform/sources/records/google_drive_content.py @@ -0,0 +1,143 @@ +"""Immutable Drive bodies captured before a record may claim content completeness.""" + +from urllib.parse import quote, urlencode + +import httpx + +from airweave.core.logging import ContextualLogger +from airweave.domains.entities.canonical.requests import BlobReference, CaptureRecord +from airweave.domains.sources.token_providers.protocol import SourceAuthProvider +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.entities.google_drive import GOOGLE_EXPORT_FORMATS +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.sources.records.google_docs_content import capture_document_parts +from airweave.platform.sources.records.google_drive import BASE, GetJSON +from airweave.platform.sources.records.google_sheets_content import capture_spreadsheet_parts +from airweave.platform.sources.records.sheets_manifest import SHEETS_MIME +from airweave.platform.sources.records.workspace_manifest import DOCS_MIME, ExportState + + +async def capture_file_content( + record: CaptureRecord, + *, + files: FileService, + get: GetJSON, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + logger: ContextualLogger, + capture_native_sheets: bool = False, +) -> CaptureRecord: + """Reuse downloads/storage; never pair bytes with metadata from another file version.""" + if record.kind == "delete" or record.completeness == "complete": + return record + payload = record.payload + mime = payload.get("mimeType") + version = payload.get("version") + if not isinstance(mime, str) or not isinstance(version, str): + return record + capabilities = payload.get("capabilities") + if isinstance(capabilities, dict) and capabilities.get("canDownload") is False: + return record + url = f"{BASE}/files/{quote(record.identity.native_id, safe='')}" + target = _download_target(url, mime) + if target is None: + return record + download_url, media_type = target + blob, export_state = await _download_representation( + download_url, media_type, mime, files=files, client=client, auth=auth, logger=logger + ) + blob = _label_representation(blob, payload.get("name"), mime) + has_native_parts = False + if mime == DOCS_MIME: + record = await capture_document_parts( + record, + export=export_state, + export_blob=blob, + files=files, + client=client, + auth=auth, + ) + has_native_parts = True + elif mime == SHEETS_MIME and capture_native_sheets: + record = await capture_spreadsheet_parts( + record, export=export_state, export_blob=blob, files=files, client=client, auth=auth + ) + has_native_parts = True + elif blob is None: + return record + latest = await get(url, params={"fields": "version", "supportsAllDrives": "true"}) + if latest.get("version") != version: + raise ValueError( + "Drive file changed during content capture; retry before advancing checkpoint" + ) + if has_native_parts: + return record + return record.model_copy( + update={"blobs": (blob,), "content_hash": blob.sha256, "completeness": "complete"} + ) + + +def _label_representation( + blob: BlobReference | None, name: object, mime: str +) -> BlobReference | None: + """Native document titles need the selected export extension; binary names are exact.""" + if blob is None or not isinstance(name, str) or not name: + return blob + export = GOOGLE_EXPORT_FORMATS.get(mime) + filename = name + export[1] if export and not name.lower().endswith(export[1]) else name + return blob.model_copy(update={"filename": filename}) + + +def _download_target(url: str, mime: str) -> tuple[str, str] | None: + if not mime.startswith("application/vnd.google-apps."): + return url + "?alt=media&supportsAllDrives=true", mime + export = GOOGLE_EXPORT_FORMATS.get(mime) + if export is None: + return None + return url + "/export?" + urlencode({"mimeType": export[0]}), export[0] + + +async def _download_representation( + url: str, + media_type: str, + mime: str, + *, + files: FileService, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + logger: ContextualLogger, +) -> tuple[BlobReference | None, ExportState]: + """Export-size limits are supported gaps; unrelated provider failures propagate.""" + try: + blob = await files.capture_canonical_url( + url=url, client=client, auth=auth, logger=logger, media_type=media_type + ) + except httpx.HTTPStatusError as error: + if not _export_limit_reached(error, mime): + raise + return None, ExportState(status="unavailable", reason="export_size_limit") + except FileSkippedException: + return None, ExportState(status="unavailable", reason="read_size_limit") + return blob, ExportState(status="retained", blob=blob.sha256) + + +def _export_limit_reached(error: httpx.HTTPStatusError, mime: str) -> bool: + """Only the explicit native-export size failure permits retaining metadata.""" + if error.response.status_code != 403 or not mime.startswith("application/vnd.google-apps."): + return False + try: + payload = error.response.json() + except ValueError: + return False + if not isinstance(payload, dict) or not isinstance(payload.get("error"), dict): + return False + errors = payload["error"].get("errors") + return ( + isinstance(errors, list) + and bool(errors) + and all( + isinstance(item, dict) and item.get("reason") == "exportSizeLimitExceeded" + for item in errors + ) + ) diff --git a/backend/airweave/platform/sources/records/google_drive_pages.py b/backend/airweave/platform/sources/records/google_drive_pages.py new file mode 100644 index 0000000000..e00505e688 --- /dev/null +++ b/backend/airweave/platform/sources/records/google_drive_pages.py @@ -0,0 +1,261 @@ +"""Drive pages retain native identities; SQL owns acknowledgements and checkpoint promotion.""" + +import hashlib +from collections.abc import Awaitable, Callable +from typing import Annotated, Literal +from urllib.parse import quote + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CycleConfiguration, + ProviderCheckpoint, +) +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + CapturePlan, + InvalidCaptureCheckpoint, +) +from airweave.domains.entities.canonical.requests import CaptureRecord +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceEntityNotFoundError +from airweave.platform.sources.records.google_drive import BASE, GetJSON, file_record + +NativeID = Annotated[str, Field(min_length=1, max_length=256)] +Token = Annotated[str, Field(min_length=1, max_length=8192)] + + +class _ParameterError(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + location: str | None = None + locationType: str | None = None + reason: str | None = None + + +class _ErrorBody(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + errors: list[_ParameterError] = Field(default_factory=list) + + +class _ErrorEnvelope(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + error: _ErrorBody + + +def rejected_listing_token(raw: dict) -> bool: + """Only parameter-specific native evidence permits discarding an inventory token.""" + try: + body = _ErrorEnvelope.model_validate(raw) + except ValidationError: + return False + return any( + item.location == "pageToken" + and item.locationType == "parameter" + and item.reason in {"invalid", "badRequest", "invalidArgument"} + for item in body.error.errors + ) + + +class _File(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + id: NativeID + + +class _Change(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + changeType: Literal["file", "drive"] + fileId: NativeID | None = None + + @model_validator(mode="after") + def identity(self): + if self.changeType == "file" and self.fileId is None: + raise ValueError("Drive file change omitted its identity") + return self + + +class _Page(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + files: list[_File] = Field(default_factory=list, max_length=100) + changes: list[_Change] = Field(default_factory=list, max_length=100) + nextPageToken: Token | None = None + newStartPageToken: Token | None = None + incompleteSearch: bool = False + + +class _Boundary(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + page_token: Token + + +class _Progress(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + version: Literal[1] = 1 + phase: Literal["list", "changes", "done"] + boundary: Token + token: Token | None = None + pending: tuple[NativeID, ...] = Field(default=(), max_length=100) + loaded: bool = False + terminal: Token | None = None + recent: tuple[str, ...] = Field(default=(), max_length=128) + + @model_validator(mode="after") + def valid(self): + if len(set(self.pending)) != len(self.pending): + raise ValueError("Drive pending identities must be unique") + if self.terminal and (self.phase != "changes" or self.token is not None): + raise ValueError("Drive terminal boundary conflicts with page continuation") + return self + + def continuation(self): + return ScanContinuation( + value=type(self).model_validate(self.model_dump()).model_dump(mode="json") + ) + + +class DrivePages: + """Acquire bounded native pages through the existing authenticated transport.""" + + def __init__(self, get: GetJSON): + """Retain only the authenticated read function, never durable state.""" + self.get = get + + async def prepare( + self, previous: CaptureCycle | None, configuration: CycleConfiguration + ) -> CapturePlan: + """Choose changes only from matching completed capture evidence.""" + if ( + previous + and previous.configuration == configuration + and previous.last_full_capture + and previous.promoted_checkpoint + ): + return CapturePlan( + mode="changes", starting_checkpoint=previous.promoted_checkpoint.checkpoint + ) + raw = await self.get(f"{BASE}/changes/startPageToken", params={"supportsAllDrives": "true"}) + return CapturePlan( + starting_checkpoint=ProviderCheckpoint( + value=_Boundary(page_token=raw["startPageToken"]).model_dump() + ) + ) + + @staticmethod + def initial(cycle: CaptureCycle) -> ScanContinuation: + """Resume the original persisted boundary, including after a crash.""" + if cycle.starting_checkpoint is None: + raise ValueError("Drive requires a persisted native boundary") + boundary = _Boundary.model_validate(cycle.starting_checkpoint.value).page_token + return _Progress( + phase="changes" if cycle.mode == "changes" else "list", + boundary=boundary, + token=boundary if cycle.mode == "changes" else None, + ).continuation() + + async def current(self, native_id: str) -> CaptureRecord: + """Only exact current404 proves the file unavailable to this connection.""" + try: + raw = await self.get( + f"{BASE}/files/{quote(native_id, safe='')}", + params={"fields": "*", "supportsAllDrives": "true"}, + ) + except SourceEntityNotFoundError: + return file_record({"id": native_id}, removed=True) + if raw.get("id") != native_id: + raise ValueError("Drive exact read returned a different file identity") + return file_record(raw) + + async def page( + self, + continuation: ScanContinuation, + hydrate: Callable[[CaptureRecord], Awaitable[CaptureRecord]], + ) -> CapturePage: + """Hydrate one file while retaining at most100 remaining native identities.""" + state = _Progress.model_validate(continuation.value) + if state.phase == "done": + raise ValueError("Completed Drive scan cannot fetch another page") + if not state.pending and not state.loaded: + state = await self._load(state) + records = () + if state.pending: + record = await hydrate(await self.current(state.pending[0])) + records = (record,) + state = state.model_copy(update={"pending": state.pending[1:]}) + final = False + checkpoint = None + if not state.pending: + if state.token is not None: + state = state.model_copy(update={"loaded": False}) + elif state.phase == "list": + state = state.model_copy( + update={ + "phase": "changes", + "token": state.boundary, + "loaded": False, + "recent": (), + } + ) + else: + if state.terminal is None: + raise ValueError("Drive final changes page omitted newStartPageToken") + checkpoint = ProviderCheckpoint(value={"page_token": state.terminal}) + state = state.model_copy(update={"phase": "done", "terminal": None}) + final = True + return CapturePage( + records=records, + continuation=state.continuation(), + final=final, + provider_checkpoint=checkpoint, + ) + + async def _load(self, state: _Progress): + params = { + "pageSize": 100, + "fields": "files(id),nextPageToken,incompleteSearch" + if state.phase == "list" + else "changes(fileId,changeType),nextPageToken,newStartPageToken", + "spaces": "drive", + "includeItemsFromAllDrives": "true", + "supportsAllDrives": "true", + } + if state.token: + params["pageToken"] = state.token + if state.phase == "list": + params["corpora"] = "allDrives" + else: + params.update(includeRemoved="true", includeCorpusRemovals="true") + page = _Page.model_validate( + await self.get( + f"{BASE}/{'files' if state.phase == 'list' else 'changes'}", params=params + ) + ) + if page.incompleteSearch: + raise ValueError("Drive reported incomplete enumeration; checkpoint was not advanced") + if any(item.changeType == "drive" for item in page.changes): + raise InvalidCaptureCheckpoint("Drive membership changed; fresh full capture required") + if ( + state.phase == "changes" + and page.nextPageToken is None + and page.newStartPageToken is None + ): + raise ValueError("Drive changes omitted terminal boundary") + recent = state.recent + if page.nextPageToken: + digest = hashlib.sha256(page.nextPageToken.encode()).hexdigest() + if digest in recent: + raise ValueError("Drive pagination repeated a recent cursor") + recent = (*recent[-127:], digest) + identities = ( + [item.id for item in page.files] + if state.phase == "list" + else [item.fileId for item in page.changes] + ) + return state.model_copy( + update={ + "pending": tuple(dict.fromkeys(identities)), + "token": page.nextPageToken, + "terminal": page.newStartPageToken if state.phase == "changes" else None, + "recent": recent, + "loaded": True, + } + ) diff --git a/backend/airweave/platform/sources/records/google_sheets_content.py b/backend/airweave/platform/sources/records/google_sheets_content.py new file mode 100644 index 0000000000..1f3b251c28 --- /dev/null +++ b/backend/airweave/platform/sources/records/google_sheets_content.py @@ -0,0 +1,285 @@ +"""Bounded native grid acquisition; Drive owns inventory, identity and publication.""" + +import asyncio +import json +from time import monotonic +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, Field, JsonValue, TypeAdapter + +from airweave.domains.entities.canonical.requests import BlobReference, CaptureRecord +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.bounded_response import bounded_response_bytes +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.records.sheets_manifest import ( + GridBounds, + GridGap, + GridPart, + SheetsState, + WorkspaceManifestV2, + parse_sheet_manifest, +) +from airweave.platform.sources.records.sheets_models import ( + SheetProperties, + a1_range, + metadata_shape, + validate_grid, + validate_partition, +) +from airweave.platform.sources.records.workspace_manifest import ( + MANIFEST_MIME, + ExportState, + canonical_json, +) + +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +MAX_NATIVE_BYTES = 32 * 1024 * 1024 +MAX_GRID_REQUESTS = 32 +MAX_CAPTURE_SECONDS = 120 +_JSON = TypeAdapter(dict[str, JsonValue]) + + +async def read_native_sheet( + file_id: str, + *, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + range_name: str | None = None, + timeout: float = 60, +) -> dict[str, JsonValue]: + """GET retains read-only scope compatibility; never interpret HTTP errors as gaps.""" + url = "https://sheets.googleapis.com/v4/spreadsheets/" + quote(file_id, safe="") + params = {"commentsViewMode": "COMMENTS_VIEW_MODE_OMITTED"} + if range_name is not None: + params.update(ranges=range_name, includeGridData="true") + for attempt in range(2): + headers = await authorization_headers(auth, refresh=attempt == 1) + headers["Accept-Encoding"] = "identity" + async with client.stream( + "GET", url, headers=headers, params=params, timeout=timeout + ) as response: + if response.status_code == 401 and auth.supports_refresh and attempt == 0: + continue + if not response.is_success: + raise_for_status( + httpx.Response( + response.status_code, + headers=response.headers, + content=b"", + request=response.request, + ), + source_short_name="google_drive", + token_provider_kind=auth.provider_kind, + ) + if response.headers.get("content-encoding", "identity").lower() != "identity": + raise ValueError("Sheets capture requires identity response encoding") + body = await bounded_response_bytes(response, MAX_RESPONSE_BYTES, label="Sheets JSON") + data = _JSON.validate_python(json.loads(body)) + metadata_shape(data, file_id) + return data + raise ValueError("Sheets authentication refresh did not complete") + + +def remaining_ranges(properties: SheetProperties, row: int, column: int) -> tuple[GridBounds, ...]: + """Compact the unrequested suffix into at most two rectangles, not millions of tiles.""" + grid = properties.gridProperties + if grid is None: + raise ValueError("GRID sheet has no dimensions") + common = {"sheet_id": properties.sheetId, "end_column": grid.columnCount} + result = [] + end_row = min(row + 100, grid.rowCount) + if column < grid.columnCount and row < grid.rowCount: + result.append(GridBounds(**common, start_row=row, end_row=end_row, start_column=column)) + if end_row < grid.rowCount: + result.append( + GridBounds(**common, start_row=end_row, end_row=grid.rowCount, start_column=0) + ) + return tuple(result) + + +def grid_tiles(properties: SheetProperties): + """Lazily plan fixed rectangles; large empty allocated grids are never materialized.""" + grid = properties.gridProperties + if grid is None: + raise ValueError("GRID sheet has no dimensions") + for row in range(0, grid.rowCount, 100): + for column in range(0, grid.columnCount, 100): + yield GridBounds( + sheet_id=properties.sheetId, + start_row=row, + end_row=min(row + 100, grid.rowCount), + start_column=column, + end_column=min(column + 100, grid.columnCount), + ) + + +class GridAcquisition(BaseModel): + """One file's transient budget, retained descriptors and exact unknown ranges.""" + + started: float + used_bytes: int + requests: int = 0 + blobs: list[BlobReference] + parts: list[GridPart] = Field(default_factory=list) + gaps: list[GridGap] = Field(default_factory=list) + + async def capture_sheet( + self, + properties: SheetProperties, + *, + file_id: str, + files: FileService, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + ) -> None: + """Stop the native scan explicitly at the budget; never imply blank unknown ranges.""" + if properties.sheetType != "GRID": + self.gaps.append(GridGap(sheet_id=properties.sheetId, reason="unsupported_sheet_type")) + return + for bounds in grid_tiles(properties): + remaining = MAX_CAPTURE_SECONDS - (monotonic() - self.started) + if ( + self.requests >= MAX_GRID_REQUESTS + or self.used_bytes >= MAX_NATIVE_BYTES + or remaining <= 0 + ): + self.gaps.extend( + GridGap(sheet_id=properties.sheetId, bounds=b, reason="capture_budget") + for b in remaining_ranges(properties, bounds.start_row, bounds.start_column) + ) + return + self.requests += 1 + try: + async with asyncio.timeout(remaining): + content = await read_grid_bytes( + file_id, + properties, + bounds, + client=client, + auth=auth, + timeout=min(60, remaining), + ) + except TimeoutError: + self.gaps.extend( + GridGap(sheet_id=properties.sheetId, bounds=b, reason="capture_budget") + for b in remaining_ranges(properties, bounds.start_row, bounds.start_column) + ) + return + except FileSkippedException: + self.gaps.append( + GridGap(sheet_id=properties.sheetId, bounds=bounds, reason="read_size_limit") + ) + continue + if self.used_bytes + len(content) > MAX_NATIVE_BYTES: + self.gaps.append( + GridGap(sheet_id=properties.sheetId, bounds=bounds, reason="capture_budget") + ) + self.used_bytes = MAX_NATIVE_BYTES + continue + blob = await files.store_canonical_blob(content, media_type="application/json") + self.used_bytes += len(content) + self.blobs.append(blob) + self.parts.append(GridPart(bounds=bounds, blob=blob.sha256)) + + +async def read_grid_bytes( + file_id: str, + properties: SheetProperties, + bounds: GridBounds, + *, + client: AirweaveHttpClient, + auth: SourceAuthProvider, + timeout: float, +) -> bytes: + """Check stable native sheet identity as well as the outer Drive version fence.""" + data = await read_native_sheet( + file_id, + client=client, + auth=auth, + range_name=a1_range(properties.title, bounds), + timeout=timeout, + ) + response = metadata_shape(data, file_id) + if [sheet.properties.sheetId for sheet in response.sheets] != [properties.sheetId]: + raise ValueError("Requested grid returned different sheets") + if response.sheets[0].properties != properties: + raise ValueError("Sheet identity or dimensions changed during acquisition") + validate_grid(data, file_id, bounds) + return canonical_json(data) + + +async def capture_spreadsheet_parts( + record: CaptureRecord, + *, + export: ExportState, + export_blob: BlobReference | None, + files: FileService, + client: AirweaveHttpClient, + auth: SourceAuthProvider, +) -> CaptureRecord: + """Keep original responses and exact requested bounds; caller fences Drive version.""" + started = monotonic() + metadata = await read_native_sheet(record.identity.native_id, client=client, auth=auth) + shape = metadata_shape(metadata, record.identity.native_id) + raw = canonical_json(metadata) + metadata_blob = await files.store_canonical_blob(raw, media_type="application/json") + blobs = [metadata_blob] + ([export_blob] if export_blob else []) + acquisition = GridAcquisition(started=started, used_bytes=len(raw), blobs=blobs) + for sheet in shape.sheets: + await acquisition.capture_sheet( + sheet.properties, + file_id=record.identity.native_id, + files=files, + client=client, + auth=auth, + ) + parts, gaps, blobs = acquisition.parts, acquisition.gaps, acquisition.blobs + manifest = WorkspaceManifestV2( + file_id=record.identity.native_id, + drive_version=record.payload["version"], + export=export, + native=SheetsState( + status="partial" if gaps else "complete", + metadata_blob=metadata_blob.sha256, + parts=tuple(parts), + missing=tuple(gaps), + ), + ) + validate_partition(manifest, metadata) + content = canonical_json(manifest.model_dump(mode="json")) + marked = (await files.store_canonical_blob(content, media_type=MANIFEST_MIME)).model_copy( + update={"role": "representation_manifest"} + ) + unique: dict[str, BlobReference] = {} + for blob in blobs: + previous = unique.get(blob.sha256) + if previous is not None and (previous.key, previous.size_bytes) != ( + blob.key, + blob.size_bytes, + ): + raise ValueError("Identical spreadsheet bytes have conflicting descriptors") + if previous is not None and previous.media_type != blob.media_type: + blob = blob.model_copy(update={"media_type": "application/octet-stream"}) + unique[blob.sha256] = blob + retained = tuple(unique[key] for key in sorted(unique)) + (marked,) + parse_sheet_manifest( + content, + file_id=record.identity.native_id, + drive_version=record.payload["version"], + blobs=retained, + ) + return CaptureRecord.model_validate( + record.model_dump() + | { + "blobs": retained, + "content_hash": marked.sha256, + "completeness": "partial" if gaps else "complete", + } + ) diff --git a/backend/airweave/platform/sources/records/sheets_manifest.py b/backend/airweave/platform/sources/records/sheets_manifest.py new file mode 100644 index 0000000000..7aee0d25e3 --- /dev/null +++ b/backend/airweave/platform/sources/records/sheets_manifest.py @@ -0,0 +1,110 @@ +"""Versioned native spreadsheet coverage under a single Drive file identity.""" + +import hashlib +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from airweave.domains.entities.canonical.requests import BlobReference +from airweave.platform.sources.records.workspace_manifest import ExportState, Sha256 + +SHEETS_MIME = "application/vnd.google-apps.spreadsheet" + + +class GridBounds(BaseModel): + """Zero-based, exclusive bounds; missing native cells inside a read range are blank.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + sheet_id: int = Field(ge=0) + start_row: int = Field(ge=0) + end_row: int = Field(gt=0) + start_column: int = Field(ge=0) + end_column: int = Field(gt=0) + + @model_validator(mode="after") + def nonempty(self): + """Reject reversed or empty native ranges.""" + if self.end_row <= self.start_row or self.end_column <= self.start_column: + raise ValueError("Grid bounds must be nonempty") + return self + + +class GridPart(BaseModel): + """One successfully retained response for a precise requested rectangle.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + bounds: GridBounds + blob: Sha256 + + +class GridGap(BaseModel): + """An explicitly unretained range or unsupported native sheet.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + sheet_id: int = Field(ge=0) + bounds: GridBounds | None = None + reason: Literal["capture_budget", "read_size_limit", "unsupported_sheet_type"] + + @model_validator(mode="after") + def identity(self): + """Bind a missing range to its stated sheet.""" + if self.reason == "unsupported_sheet_type": + if self.bounds is not None: + raise ValueError("Unsupported sheet has no grid range") + elif self.bounds is None or self.bounds.sheet_id != self.sheet_id: + raise ValueError("Missing range must belong to its sheet") + return self + + +class SheetsState(BaseModel): + """Structured coverage independent of exported-file availability.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + kind: Literal["sheets"] = "sheets" + status: Literal["complete", "partial"] + metadata_blob: Sha256 + parts: tuple[GridPart, ...] = () + missing: tuple[GridGap, ...] = () + comments: Literal["omitted"] = "omitted" + embedded_media: Literal["not_retained"] = "not_retained" + calculated_values: Literal["observed_at_capture"] = "observed_at_capture" + + @model_validator(mode="after") + def coverage(self): + """Require explicit gaps for every partial capture.""" + if (self.status == "partial") != bool(self.missing): + raise ValueError("Grid status must agree with missing ranges") + return self + + +class WorkspaceManifestV2(BaseModel): + """Sheets extension; immutable Docs v1 and export-only originals remain readable.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[2] = 2 + file_id: str = Field(min_length=1) + drive_version: str = Field(pattern=r"^[0-9]+$") + export: ExportState + native: SheetsState + + +def parse_sheet_manifest( + content: bytes, *, file_id: str, drive_version: str, blobs: tuple[BlobReference, ...] +) -> WorkspaceManifestV2: + """All semantic descriptors must resolve to exactly the owning record's bytes.""" + manifest = WorkspaceManifestV2.model_validate_json(content) + if (manifest.file_id, manifest.drive_version) != (file_id, drive_version): + raise ValueError("Spreadsheet manifest identity/version mismatch") + marked = [blob for blob in blobs if blob.role == "representation_manifest"] + digest = hashlib.sha256(content).hexdigest() + if len(marked) != 1 or (marked[0].sha256, marked[0].size_bytes) != (digest, len(content)): + raise ValueError("Spreadsheet manifest is not the uniquely retained manifest") + by_digest = {blob.sha256: blob for blob in blobs} + if len(by_digest) != len(blobs) or any(blob.source_path is not None for blob in blobs): + raise ValueError("Spreadsheet descriptors are ambiguous") + refs = {manifest.native.metadata_blob, *(part.blob for part in manifest.native.parts)} + if manifest.export.blob: + refs.add(manifest.export.blob) + if digest in refs or refs != set(by_digest) - {digest}: + raise ValueError("Spreadsheet manifest does not account for every retained part") + return manifest diff --git a/backend/airweave/platform/sources/records/sheets_models.py b/backend/airweave/platform/sources/records/sheets_models.py new file mode 100644 index 0000000000..7a93c6f875 --- /dev/null +++ b/backend/airweave/platform/sources/records/sheets_models.py @@ -0,0 +1,176 @@ +"""Typed native Sheets shape and exact retained-grid admission.""" + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from airweave.platform.sources.records.sheets_manifest import GridBounds, WorkspaceManifestV2 + + +class GridDimensions(BaseModel): + """Allocated grid dimensions from native sheet metadata.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + rowCount: int = Field(ge=0) + columnCount: int = Field(ge=0) + + +class SheetProperties(BaseModel): + """Identity and dimensions needed for version-consistent range acquisition.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + sheetId: int = Field(ge=0) + title: str = Field(min_length=1) + sheetType: str = "GRID" + gridProperties: GridDimensions | None = None + + +class SheetMetadata(BaseModel): + """Native sheet metadata boundary; unmodeled fields remain in retained JSON.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + properties: SheetProperties + + +class SpreadsheetMetadata(BaseModel): + """Workbook identity and sheet inventory used to admit grid coverage.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + spreadsheetId: str + sheets: tuple[SheetMetadata, ...] + + +def metadata_shape(data: dict[str, JsonValue], file_id: str) -> SpreadsheetMetadata: + """Validate native identity without stripping fields from retained JSON.""" + try: + metadata = SpreadsheetMetadata.model_validate(data) + except ValidationError: + raise ValueError("Malformed native spreadsheet metadata") from None + if metadata.spreadsheetId != file_id: + raise ValueError("Spreadsheet response belongs to another file") + ids = [s.properties.sheetId for s in metadata.sheets] + if len(ids) != len(set(ids)): + raise ValueError("Spreadsheet contains duplicate sheet IDs") + return metadata + + +class NativeRow(BaseModel): + """Native sparse cells, preserving all returned fields.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + values: tuple[dict[str, JsonValue], ...] = () + + +class NativeGrid(BaseModel): + """Native sparse block with explicit zero-based offsets.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + startRow: int = Field(default=0, ge=0) + startColumn: int = Field(default=0, ge=0) + rowData: tuple[NativeRow, ...] = () + + +class SheetGrid(BaseModel): + """Grid blocks supplied for one requested native sheet.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + data: tuple[NativeGrid, ...] = () + + +def validate_grid(data: dict[str, JsonValue], file_id: str, bounds: GridBounds) -> None: + """Provider sparsity is valid only inside the explicit requested rectangle.""" + shape = metadata_shape(data, file_id) + if [sheet.properties.sheetId for sheet in shape.sheets] != [bounds.sheet_id]: + raise ValueError("Grid response must identify its exact requested sheet") + sheets = data["sheets"] + try: + sheet = SheetGrid.model_validate(sheets[0]) + except ValidationError: + raise ValueError("Malformed native spreadsheet grid") from None + seen: set[tuple[int, int]] = set() + for block in sheet.data: + if not block.rowData: + continue + if not ( + bounds.start_row <= block.startRow < bounds.end_row + and bounds.start_column <= block.startColumn < bounds.end_column + ): + raise ValueError("Grid response starts outside the requested bounds") + if block.startRow + len(block.rowData) > bounds.end_row: + raise ValueError("Grid response exceeds requested rows") + if any(block.startColumn + len(row.values) > bounds.end_column for row in block.rowData): + raise ValueError("Grid response exceeds requested columns") + coordinates = { + (row_index, column_index) + for row_index, row in enumerate(block.rowData, start=block.startRow) + for column_index in range(block.startColumn, block.startColumn + len(row.values)) + } + if seen.intersection(coordinates): + raise ValueError("Grid response contains overlapping cells") + seen.update(coordinates) + + +def validate_partition(manifest: WorkspaceManifestV2, metadata: dict[str, JsonValue]) -> None: + """No overlap, missing range, foreign sheet or optimistic complete declaration.""" + shape = metadata_shape(metadata, manifest.file_id) + identities = {sheet.properties.sheetId for sheet in shape.sheets} + if any(p.bounds.sheet_id not in identities for p in manifest.native.parts): + raise ValueError("Retained grid references an absent sheet") + if any(g.sheet_id not in identities for g in manifest.native.missing): + raise ValueError("Grid gap references an absent sheet") + for sheet in shape.sheets: + props = sheet.properties + parts = [p.bounds for p in manifest.native.parts if p.bounds.sheet_id == props.sheetId] + gaps = [g for g in manifest.native.missing if g.sheet_id == props.sheetId] + if props.sheetType != "GRID": + if parts or len(gaps) != 1 or gaps[0].reason != "unsupported_sheet_type": + raise ValueError("Unsupported sheet must have explicit unavailable coverage") + continue + dims = props.gridProperties + if dims is None or any(g.bounds is None for g in gaps): + raise ValueError("GRID coverage requires dimensions and explicit ranges") + bounds = parts + [g.bounds for g in gaps] + _validate_rectangles(bounds, dims) + + +def _validate_rectangles(bounds: list[GridBounds], dims: GridDimensions) -> None: + area = 0 + for index, current in enumerate(bounds): + if current.end_row > dims.rowCount or current.end_column > dims.columnCount: + raise ValueError("Grid coverage exceeds sheet dimensions") + area += (current.end_row - current.start_row) * (current.end_column - current.start_column) + for previous in bounds[:index]: + if max(previous.start_row, current.start_row) < min( + previous.end_row, current.end_row + ) and max(previous.start_column, current.start_column) < min( + previous.end_column, current.end_column + ): + raise ValueError("Grid coverage overlaps") + if area != dims.rowCount * dims.columnCount: + raise ValueError("Grid coverage does not account for every configured cell") + + +class SpreadsheetCell(BaseModel): + """One native cell with absolute zero-based coordinates.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + row: int = Field(ge=0) + column: int = Field(ge=0) + value: dict[str, JsonValue] + + +def column_name(index: int) -> str: + """Convert a zero-based column to its native A1 name without a sheet-title parser.""" + result = "" + number = index + 1 + while number: + number, remainder = divmod(number - 1, 26) + result = chr(65 + remainder) + result + return result + + +def a1_range(title: str, bounds: GridBounds) -> str: + """Quote the native title while stable sheet IDs remain the admission authority.""" + escaped = title.replace("'", "''") + return ( + f"'{escaped}'!{column_name(bounds.start_column)}{bounds.start_row + 1}:" + f"{column_name(bounds.end_column - 1)}{bounds.end_row}" + ) diff --git a/backend/airweave/platform/sources/records/workspace_manifest.py b/backend/airweave/platform/sources/records/workspace_manifest.py new file mode 100644 index 0000000000..32f99b82d2 --- /dev/null +++ b/backend/airweave/platform/sources/records/workspace_manifest.py @@ -0,0 +1,169 @@ +"""Versioned Docs capture provenance. Drive remains the sole record/access owner.""" + +import hashlib +import json +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from airweave.domains.entities.canonical.requests import BlobReference + +Sha256 = Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")] +DOCS_MIME = "application/vnd.google-apps.document" +MANIFEST_MIME = "application/vnd.almanac.workspace-manifest+json" + + +class ExportState(BaseModel): + """A retained export or a specific supported acquisition limitation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + status: Literal["retained", "unavailable"] + blob: Sha256 | None = None + reason: Literal["export_size_limit", "unsupported", "read_size_limit"] | None = None + + @model_validator(mode="after") + def coverage(self): + """Require explicit, internally consistent representation coverage.""" + if self.status == "retained": + if self.blob is None or self.reason is not None: + raise ValueError("Retained export requires only a blob") + elif self.blob is not None or self.reason is None: + raise ValueError("Unavailable export requires only a reason") + return self + + +class DocsGap(BaseModel): + """An explicit missing portion of this acquisition policy.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + source_path: str | None = Field(default=None, pattern=r"^/") + reason: Literal["read_size_limit", "unsupported_media", "capture_budget"] + + +class DocsMediaPart(BaseModel): + """A native response location backed by retained immutable bytes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + source_path: str = Field(pattern=r"^/") + blob: Sha256 + + +class DocsState(BaseModel): + """Declared request policy and native response availability, not revision history.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + kind: Literal["docs"] = "docs" + status: Literal["complete", "partial", "unavailable"] + document_blob: Sha256 | None = None + include_tabs_content: Literal[True] = True + suggestions_view: Literal["DEFAULT_FOR_CURRENT_ACCESS"] = "DEFAULT_FOR_CURRENT_ACCESS" + comments_view: Literal["COMMENTS_VIEW_MODE_OMITTED"] = "COMMENTS_VIEW_MODE_OMITTED" + embedded_media: Literal["not_retained", "retained", "partial"] + media: tuple[DocsMediaPart, ...] = () + missing: tuple[DocsGap, ...] = () + + @model_validator(mode="after") + def coverage(self): + """Require explicit, internally consistent representation coverage.""" + if (self.status == "unavailable") != (self.document_blob is None): + raise ValueError("Docs response availability must match its blob") + if self.status == "unavailable" and not self.missing: + raise ValueError("Unavailable Docs response requires an explicit gap") + if self.status == "complete" and any(g.reason != "unsupported_media" for g in self.missing): + raise ValueError("Complete Docs fields cannot contain non-media gaps") + if self.embedded_media == "not_retained" and self.media: + raise ValueError("Unretained Docs media cannot reference bytes") + if self.embedded_media == "retained" and any( + g.reason == "unsupported_media" for g in self.missing + ): + raise ValueError("Retained Docs media cannot contain missing media") + if len({p.source_path for p in self.media}) != len(self.media): + raise ValueError("Docs media locations must be unique") + return self + + +class WorkspaceManifestV1(BaseModel): + """One file/version's supported representations; native Sheets is not implemented.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[1] = 1 + file_id: str = Field(min_length=1) + drive_version: str = Field(pattern=r"^[0-9]+$") + export: ExportState + native: DocsState + + +def canonical_json(value: JsonValue) -> bytes: + """Stable JSON preserves all native values, independent of object key order.""" + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + ).encode("utf-8") + + +def validate_document(document: dict[str, JsonValue], *, file_id: str) -> None: + """Validate identity and all-tabs shape without dropping unknown native fields.""" + if document.get("documentId") != file_id: + raise ValueError("Docs response belongs to another file") + tabs = document.get("tabs") + if not isinstance(tabs, list) or not tabs: + raise ValueError("Docs response is missing all-tabs content") + seen: set[str] = set() + pending = list(tabs) + while pending: + tab = pending.pop() + if not isinstance(tab, dict) or not isinstance(tab.get("documentTab"), dict): + raise ValueError("Docs response has an unsupported tab structure") + properties = tab.get("tabProperties") + tab_id = properties.get("tabId") if isinstance(properties, dict) else None + if not isinstance(tab_id, str) or not tab_id or tab_id in seen: + raise ValueError("Docs tab identity is missing or duplicated") + seen.add(tab_id) + children = tab.get("childTabs", []) + if not isinstance(children, list): + raise ValueError("Docs child tabs are malformed") + pending.extend(children) + + +def document_media_gaps(document: dict[str, JsonValue]) -> tuple[DocsGap, ...]: + """Embedded object bytes are not downloaded through unaudited content URLs.""" + gaps = [] + pending: list[tuple[str, JsonValue]] = [("", document)] + while pending: + path, value = pending.pop() + if isinstance(value, dict): + for key, child in value.items(): + location = path + "/" + key.replace("~", "~0").replace("/", "~1") + if key == "embeddedObject": + gaps.append(DocsGap(source_path=location, reason="unsupported_media")) + else: + pending.append((location, child)) + elif isinstance(value, list): + pending.extend((f"{path}/{index}", child) for index, child in enumerate(value)) + return tuple(sorted(gaps, key=lambda gap: gap.source_path or "")) + + +def parse_manifest( + content: bytes, *, file_id: str, drive_version: str, blobs: tuple[BlobReference, ...] +) -> WorkspaceManifestV1: + """Reject foreign/missing/recursive references; Workspace alone deduplicates by digest.""" + manifest = WorkspaceManifestV1.model_validate_json(content) + if manifest.file_id != file_id or manifest.drive_version != drive_version: + raise ValueError("Workspace manifest identity/version does not match its original") + marked = [blob for blob in blobs if blob.role == "representation_manifest"] + digest = hashlib.sha256(content).hexdigest() + if len(marked) != 1 or marked[0].sha256 != digest or marked[0].size_bytes != len(content): + raise ValueError("Workspace manifest is not the uniquely retained manifest") + by_digest: dict[str, BlobReference] = {} + for blob in blobs: + if blob.sha256 in by_digest: + raise ValueError("Workspace descriptors must be unambiguous by digest") + if blob.source_path is not None: + raise ValueError("Workspace parts cannot claim locations in native Drive metadata") + by_digest[blob.sha256] = blob + references = {part.blob for part in manifest.native.media} + references.update( + value for value in (manifest.export.blob, manifest.native.document_blob) if value + ) + if digest in references or references != set(by_digest) - {digest}: + raise ValueError("Workspace manifest does not exactly account for its retained parts") + return manifest diff --git a/backend/airweave/platform/sources/salesforce.py b/backend/airweave/platform/sources/salesforce.py index 866defdfcc..6bf0eb19cf 100644 --- a/backend/airweave/platform/sources/salesforce.py +++ b/backend/airweave/platform/sources/salesforce.py @@ -34,12 +34,12 @@ _parse_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/servicenow.py b/backend/airweave/platform/sources/servicenow.py index a2ea6695b8..b8a639b03e 100644 --- a/backend/airweave/platform/sources/servicenow.py +++ b/backend/airweave/platform/sources/servicenow.py @@ -29,12 +29,12 @@ ServiceNowProblemEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod TABLE_API_PATH = "/api/now/table" diff --git a/backend/airweave/platform/sources/sharepoint.py b/backend/airweave/platform/sources/sharepoint.py index a7a68770bf..170225d7ab 100644 --- a/backend/airweave/platform/sources/sharepoint.py +++ b/backend/airweave/platform/sources/sharepoint.py @@ -48,13 +48,13 @@ _parse_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/sharepoint2019v2/client.py b/backend/airweave/platform/sources/sharepoint2019v2/client.py index 2ae7ff7eea..fbb3ee1ac1 100644 --- a/backend/airweave/platform/sources/sharepoint2019v2/client.py +++ b/backend/airweave/platform/sources/sharepoint2019v2/client.py @@ -17,7 +17,7 @@ from httpx_ntlm import HttpNtlmAuth from tenacity import retry, stop_after_attempt -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_ntlm_auth_or_rate_limit_or_timeout, retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, diff --git a/backend/airweave/platform/sources/sharepoint_online/client.py b/backend/airweave/platform/sources/sharepoint_online/client.py index fcfd945ffc..e930f1f6b3 100644 --- a/backend/airweave/platform/sources/sharepoint_online/client.py +++ b/backend/airweave/platform/sources/sharepoint_online/client.py @@ -16,7 +16,7 @@ import httpx from tenacity import retry, stop_after_attempt -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) diff --git a/backend/airweave/platform/sources/sharepoint_online/source.py b/backend/airweave/platform/sources/sharepoint_online/source.py index 332b1fd5c6..9b65c8e756 100644 --- a/backend/airweave/platform/sources/sharepoint_online/source.py +++ b/backend/airweave/platform/sources/sharepoint_online/source.py @@ -55,13 +55,13 @@ SharePointOnlineFileDeletionEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.microsoft_sensitivity_labels import SensitivityLabelFilter from airweave.platform.sources.sharepoint_online.acl import extract_access_control from airweave.platform.sources.sharepoint_online.builders import ( build_drive_entity, diff --git a/backend/airweave/platform/sources/shopify.py b/backend/airweave/platform/sources/shopify.py index 3bcdc5282c..979a8b537b 100644 --- a/backend/airweave/platform/sources/shopify.py +++ b/backend/airweave/platform/sources/shopify.py @@ -59,12 +59,12 @@ _parse_shopify_ts, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod SHOPIFY_API_VERSION = "2024-01" diff --git a/backend/airweave/platform/sources/slab.py b/backend/airweave/platform/sources/slab.py index 10209c0dd5..3ee859244f 100644 --- a/backend/airweave/platform/sources/slab.py +++ b/backend/airweave/platform/sources/slab.py @@ -29,12 +29,12 @@ from airweave.platform.decorators import source from airweave.platform.entities.slab import SlabCommentEntity, SlabPostEntity, SlabTopicEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod SLAB_GRAPHQL_URL = "https://api.slab.com/v1/graphql" diff --git a/backend/airweave/platform/sources/slack.py b/backend/airweave/platform/sources/slack.py index 4e59082856..064a8e8c5d 100644 --- a/backend/airweave/platform/sources/slack.py +++ b/backend/airweave/platform/sources/slack.py @@ -1,17 +1,50 @@ -"""Slack source implementation for federated search.""" +"""Slack capture of accessible conversations, with optional live search.""" from __future__ import annotations +import hashlib +import json import re +from datetime import datetime, timedelta, timezone +from functools import partial from typing import Any, AsyncGenerator, Dict, List, Optional - -from tenacity import retry, stop_after_attempt +from uuid import UUID + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + StrictBool, + TypeAdapter, + ValidationError, + model_validator, +) +from tenacity import retry, retry_if_exception_type, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.exceptions import SourceAuthError -from airweave.domains.sources.token_providers.protocol import TokenProviderProtocol +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ( + CapturePage, + InvalidScanContinuation, + ScopeAccessLost, +) +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, + parent_container_key, +) +from airweave.domains.entities.canonical.scan_models import ChildScopeObservation, ScanContinuation +from airweave.domains.sources.exceptions import SourceAuthError, SourceError, SourceRateLimitError +from airweave.domains.sources.token_providers.protocol import ( + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage.file_service import FileService from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder from airweave.domains.syncs.cursors.cursor import SyncCursor @@ -21,14 +54,42 @@ from airweave.platform.entities._base import AirweaveSystemMetadata, BaseEntity, Breadcrumb from airweave.platform.entities.slack import SlackMessageEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import _parse_retry_after, raise_for_status +from airweave.platform.sources.slack_content import SlackMessageFiles, capture_slack_file +from airweave.platform.sources.slack_errors import SlackApiError from airweave.schemas.source_connection import AuthenticationMethod, OAuthType + +def _message_timestamp(value: object) -> datetime | None: + """Decode Slack decimal seconds exactly; unavailable native dates stay unknown.""" + if not isinstance(value, str) or not re.fullmatch(r"[0-9]{1,12}(?:\.[0-9]{1,6})?", value): + return None + seconds, _, fraction = value.partition(".") + try: + return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta( + seconds=int(seconds), microseconds=int(fraction.ljust(6, "0")) + ) + except OverflowError: + return None + + +def _conversation_timestamp(value: object, *, milliseconds: bool = False) -> datetime | None: + """Conversation creation uses seconds; settings updates use milliseconds.""" + if type(value) is not int or value < 0: + return None + try: + return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta( + microseconds=value * (1000 if milliseconds else 1_000_000) + ) + except OverflowError: + return None + + # Pattern for Slack mrkdwn special sequences: # <@U1234|display_name> → @display_name (user mention with label) # <@U1234> → @U1234 (user mention without label) @@ -68,6 +129,64 @@ def _replace(m: re.Match[str]) -> str: return re.sub(r" +", " ", cleaned).strip() +class SlackPrincipal(BaseModel): + """Native authorized workspace/user identity, with no display-name inference.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + ok: StrictBool + team_id: str = Field(min_length=1, pattern=r"^\S+$") + user_id: str = Field(min_length=1, pattern=r"^\S+$") + + @model_validator(mode="after") + def successful(self): + """Only an explicit successful auth.test response attests identity.""" + if not self.ok: + raise ValueError("Slack identity request did not succeed") + return self + + +class SlackHistoryCoverage(BaseModel): + """Slack omits this flag unless older history is unavailable under its limit.""" + + model_config = ConfigDict(extra="ignore") + is_limited: StrictBool = False + + +class SlackPageMetadata(BaseModel): + """Only pagination metadata is parsed; record JSON stays untouched.""" + + next_cursor: str = Field(default="", max_length=4096) + + +class SlackRootContinuation(BaseModel): + """Bounded conversation-list pagination state.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + cursor: str = Field(default="", max_length=4096) + recent: tuple[str, ...] = Field(default=(), max_length=16) + + +class SlackMessageContinuation(BaseModel): + """One history page's thread queue, never the whole conversation in memory.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + history_cursor: str = Field(default="", max_length=4096) + history_recent: tuple[str, ...] = Field(default=(), max_length=16) + history_done: bool = False + pending_threads: tuple[str, ...] = Field(default=(), max_length=100) + reply_cursor: str = Field(default="", max_length=4096) + reply_recent: tuple[str, ...] = Field(default=(), max_length=16) + + +class SlackFileContinuation(BaseModel): + """Position only in the retained parent inventory, never a mutable provider page.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + parent_id: UUID + inventory: str = Field(pattern=r"^[a-f0-9]{64}$") + next_index: int = Field(ge=0, le=100) + + @source( name="Slack", short_name="slack", @@ -81,28 +200,83 @@ def _replace(m: re.Match[str]) -> str: config_class=SlackConfig, labels=["Communication", "Messaging"], supports_continuous=False, - federated_search=True, + federated_search=False, rate_limit_level=RateLimitLevel.ORG, ) class SlackSource(BaseSource): - """Slack source connector using federated search. + """Capture all accessible conversation history and replies without search truncation.""" + + # Class-level capabilities remain available to the search source registry. + canonical_record_types = ("channel", "message", "file") + canonical_container_parents = {"message": "channel", "file": "message"} + _slack_config: SlackConfig | None = None + + @property + def slack_config(self) -> SlackConfig | None: + """The source configuration fixes its capture topology before a cycle starts.""" + return self._slack_config + + @slack_config.setter + def slack_config(self, config: SlackConfig) -> None: + self._slack_config = config + self.canonical_record_types = ( + ("channel", "message", "file") if config.capture_files else ("channel", "message") + ) + self.canonical_container_parents = ( + {"message": "channel", "file": "message"} + if config.capture_files + else {"message": "channel"} + ) - Instead of syncing all messages and files, this source searches Slack at query time - using the search.all API endpoint. This is necessary because Slack's rate limits - are too restrictive for full synchronization. - """ + _verified_principal: SlackPrincipal | None = None + + def _require_principal(self) -> SlackPrincipal: + """Unbound legacy search cannot bypass canonical capture identity checks.""" + principal = self._verified_principal + if ( + principal is None + or self.slack_config is None + or principal.team_id != self.slack_config.expected_team_id + or principal.user_id != self.slack_config.expected_user_id + ): + raise ValueError("Owned Slack capture requires an attested workspace and user") + return principal + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Existing cycle ownership includes the trusted visibility principal.""" + principal = self._require_principal() + material = {"version": 2, "team_id": principal.team_id, "user_id": principal.user_id} + if self.slack_config.capture_files: + material["capture_files"] = "message_owned_children_v1" + fingerprint = hashlib.sha256( + json.dumps( + material, + sort_keys=True, + ).encode() + ).hexdigest() + return CycleConfiguration.from_source( + fingerprint=fingerprint, + record_types=self.canonical_record_types, + container_parents=self.canonical_container_parents, + exact_parent_validation=("message",) if self.slack_config.capture_files else (), + ) @classmethod async def create( cls, *, - auth: TokenProviderProtocol, + auth: SourceAuthProvider, logger: ContextualLogger, http_client: AirweaveHttpClient, config: SlackConfig, ) -> SlackSource: """Create a new Slack source.""" - return cls(auth=auth, logger=logger, http_client=http_client) + instance = cls(auth=auth, logger=logger, http_client=http_client) + instance.slack_config = config + if config.expected_team_id is not None: + await instance.validate() + return instance # ------------------------------------------------------------------ # HTTP @@ -110,19 +284,17 @@ async def create( @retry( stop=stop_after_attempt(5), - retry=retry_if_rate_limit_or_timeout, - wait=wait_rate_limit_with_backoff, + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), reraise=True, ) async def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict: """Make authenticated GET request to Slack API with token refresh support.""" - token = await self.auth.get_token() - headers = {"Authorization": f"Bearer {token}"} + headers = await authorization_headers(self.auth) response = await self.http_client.get(url, headers=headers, params=params) if response.status_code == 401 and self.auth.supports_refresh: - new_token = await self.auth.force_refresh() - headers = {"Authorization": f"Bearer {new_token}"} + headers = await authorization_headers(self.auth, refresh=True) response = await self.http_client.get(url, headers=headers, params=params) raise_for_status( @@ -130,7 +302,351 @@ async def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict: source_short_name=self.short_name, token_provider_kind=self.auth.provider_kind, ) - return response.json() + payload = response.json() + if not payload.get("ok"): + error = payload.get("error", "unknown_error") + if error in {"ratelimited", "rate_limited"}: + raise SourceRateLimitError( + source_short_name="slack", retry_after=_parse_retry_after(response, default=60) + ) + if error in {"invalid_auth", "not_authed", "token_revoked", "account_inactive"}: + raise SourceAuthError( + "Slack authentication is unavailable", + source_short_name="slack", + status_code=401, + token_provider_kind=self.auth.provider_kind, + ) + # Do not turn denied channels, missing scopes or provider errors into empty pages. + raise SlackApiError(error) + return payload + + @staticmethod + def _capture_message(message: dict, channel_id: str) -> CaptureRecord: + """Keep native response untouched; opt-in acquisition subsequently adds blob evidence.""" + edited = message.get("edited") + edited_ts = edited.get("ts") if isinstance(edited, dict) else None + return CaptureRecord( + identity=RecordIdentity( + record_type="message", native_id=message["ts"], container_id=channel_id + ), + payload=message, + observed_at=datetime.now(timezone.utc), + completeness="partial" if message.get("files") else "complete", + source_created_at=_message_timestamp(message["ts"]), + source_updated_at=_message_timestamp(edited_ts), + ) + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Keep the established native container identity for this flat provider.""" + return self._child_scope(parent.identity, record_type) + + def _child_scope(self, parent: RecordIdentity, record_type: str) -> CompletedScope: + """Enumeration and fresh-page receipts use exactly the same scope identity.""" + if self.canonical_container_parents.get(record_type) != parent.record_type: + raise ValueError("Unsupported Slack child scope") + return CompletedScope( + record_type=record_type, + container_id=parent_container_key(parent) + if record_type == "file" + else parent.native_id, + parent=parent, + ) + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Fetch one page; record and nested reply progress are committed by the pipeline.""" + self._require_principal() + try: + if scope.record_type == "file": + return await self._file_page(scope, continuation, files, parent) + page = await self._validated_capture_page(scope, continuation) + if self.slack_config.capture_files and scope.record_type == "message": + records = tuple(self._attachment_owner(record) for record in page.records) + page = page.model_copy( + update={ + "records": records, + "child_scope_observations": tuple( + ChildScopeObservation(scope=self._child_scope(record.identity, "file")) + for record in records + ), + } + ) + return page + except ValidationError: + # Validation diagnostics may contain private provider values. + raise ValueError("Slack capture returned invalid page or continuation data") from None + + def _attachment_owner(self, record: CaptureRecord) -> CaptureRecord: + """Validate the native inventory shared by enumeration and exact owner reads.""" + self._file_inventory(record.payload) + return record.model_copy( + update={ + "payload_schema_version": 2, + "descendant_visibility_fields": ("files",), + "completeness": "complete", + } + ) + + async def refresh_known(self, record: SourceRecord, *, files: FileService) -> CaptureRecord: + """Recheck an attachment owner without treating an ambiguous miss as deletion.""" + self._require_principal() + if not self.slack_config.capture_files or record.identity.record_type != "message": + raise ValueError("Slack exact owner refresh requires attachment capture and a message") + try: + message = await self._read_known_message(record) + if message is None: + raise ValueError("Slack message access remains unconfirmed; capture is incomplete") + captured = self._capture_message(message, record.identity.container_id) + return self._attachment_owner(captured).model_copy(update={"parent": record.parent}) + except ValidationError: + raise ValueError("Slack exact message read returned invalid provider data") from None + + @staticmethod + def _file_inventory(payload: dict[str, JsonValue]) -> list[dict[str, JsonValue]]: + metadata = SlackMessageFiles.model_validate(payload).files + if len({file.id for file in metadata}) != len(metadata): + raise ValueError("Slack message contains duplicate file identities") + return payload.get("files", []) + + async def _file_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + files: FileService, + parent: SourceRecord | None, + ) -> CapturePage: + if ( + not self.slack_config.capture_files + or parent is None + or parent.identity.record_type != "message" + or parent.payload_schema_version != 2 + or parent.content_access != "available" + or parent.deleted_at is not None + or scope != self.child_scope(parent, "file") + ): + raise ValueError("Slack file scope requires its current retained message") + inventory = self._file_inventory(parent.payload) + digest = hashlib.sha256( + json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + state = ( + SlackFileContinuation.model_validate(continuation.value) + if continuation.value + else SlackFileContinuation(parent_id=parent.id, inventory=digest, next_index=0) + ) + if state.parent_id != parent.id or state.inventory != digest: + raise InvalidScanContinuation("Slack retained file inventory changed") + if state.next_index > len(inventory): + raise InvalidScanContinuation("Slack file continuation exceeds retained inventory") + if state.next_index == len(inventory): + return CapturePage(records=(), continuation=continuation, final=True) + native = inventory[state.next_index] + item = CaptureRecord( + identity=RecordIdentity( + record_type="file", native_id=native["id"], container_id=scope.container_id + ), + parent=parent.identity, + payload=native, + observed_at=datetime.now(timezone.utc), + ) + captured = await capture_slack_file(self, item, files) + following = state.model_copy(update={"next_index": state.next_index + 1}) + return CapturePage( + records=(captured,), + continuation=ScanContinuation(value=following.model_dump(mode="json")), + final=following.next_index == len(inventory), + ) + + async def _validated_capture_page( + self, scope: CompletedScope, continuation: ScanContinuation + ) -> CapturePage: + if scope.record_type == "channel" and scope.container_id is None: + state = SlackRootContinuation.model_validate(continuation.value) + items, following, recent = await self._capture_page_response( + "conversations.list", + "channels", + {"types": "public_channel,private_channel,im,mpim", "exclude_archived": "false"}, + state.cursor, + state.recent, + ) + records = tuple( + CaptureRecord( + identity=RecordIdentity(record_type="channel", native_id=item["id"]), + payload=item, + observed_at=datetime.now(timezone.utc), + source_created_at=_conversation_timestamp(item.get("created")), + source_updated_at=_conversation_timestamp( + item.get("updated"), milliseconds=True + ), + ) + for item in items + ) + return CapturePage( + records=records, + final=not following, + continuation=ScanContinuation( + value=SlackRootContinuation(cursor=following, recent=recent).model_dump( + mode="json" + ) + ), + ) + if scope.record_type != "message" or scope.container_id is None: + raise ValueError("Slack page capture requires a declared whole conversation scope") + state = SlackMessageContinuation.model_validate(continuation.value) + channel_id = scope.container_id + try: + if state.pending_threads: + items, following, recent = await self._capture_page_response( + "conversations.replies", + "messages", + {"channel": channel_id, "ts": state.pending_threads[0]}, + state.reply_cursor, + state.reply_recent, + ) + pending = state.pending_threads if following else state.pending_threads[1:] + state = state.model_copy( + update={ + "pending_threads": pending, + "reply_cursor": following, + "reply_recent": recent if following else (), + } + ) + else: + if state.history_done: + raise ValueError("Completed Slack continuation cannot fetch another page") + items, following, recent = await self._capture_page_response( + "conversations.history", + "messages", + {"channel": channel_id}, + state.history_cursor, + state.history_recent, + ) + pending = tuple( + dict.fromkeys(item["ts"] for item in items if item.get("reply_count", 0)) + ) + state = SlackMessageContinuation( + history_cursor=following, + history_recent=recent, + history_done=not following, + pending_threads=pending, + ) + except SlackApiError as exc: + if exc.code in {"channel_not_found", "not_in_channel"}: + raise ScopeAccessLost("Slack conversation access was lost") from exc + raise + return CapturePage( + records=tuple(self._capture_message(item, channel_id) for item in items), + continuation=ScanContinuation(value=state.model_dump(mode="json")), + final=state.history_done and not state.pending_threads, + ) + + async def _capture_page_response( + self, + operation: str, + item_key: str, + params: dict[str, JsonValue], + cursor: str, + recent: tuple[str, ...], + ) -> tuple[list[dict[str, JsonValue]], str, tuple[str, ...]]: + """Validate provider pagination without rewriting native record payloads.""" + try: + payload = await self._get( + f"https://slack.com/api/{operation}", + {**params, "limit": 100, **({"cursor": cursor} if cursor else {})}, + ) + except SlackApiError as exc: + if exc.code == "invalid_cursor": + raise InvalidScanContinuation("Slack page cursor expired") from exc + if operation == "conversations.replies" and exc.code == "thread_not_found": + # History queued a thread that no longer exists. Restart inventory; + # missing replies alone cannot authorize deletion or scope completion. + raise InvalidScanContinuation("Slack queued thread is no longer available") from exc + raise + if operation == "conversations.history": + coverage = SlackHistoryCoverage.model_validate(payload) + if coverage.is_limited: + raise SourceError( + "Slack history is limited by workspace visibility. " + "Capture remains incomplete; older retained messages were not reconciled.", + source_short_name="slack", + ) + items = TypeAdapter(list[dict[str, JsonValue]]).validate_python(payload.get(item_key)) + metadata = SlackPageMetadata.model_validate(payload.get("response_metadata") or {}) + following = metadata.next_cursor.strip() + if not following and payload.get("has_more"): + raise ValueError("Slack capture has more records without a cursor") + if following: + digest = hashlib.sha256(following.encode()).hexdigest() + if following == cursor or digest in recent: + raise ValueError("Slack capture repeated a pagination cursor") + recent = (*recent[-15:], digest) + return items, following, recent + + async def confirm_absent(self, record: SourceRecord) -> None: + """Require provider confirmation before hiding an omitted prior conversation.""" + self._require_principal() + if record.identity.record_type == "message": + await self._confirm_message_omission(record) + return + if record.identity.record_type != "channel" or record.identity.container_id is not None: + raise ValueError("Slack omission confirmation requires a channel or message") + native_id = record.identity.native_id + try: + await self._get("https://slack.com/api/conversations.info", {"channel": native_id}) + except SlackApiError as exc: + if exc.code not in {"channel_not_found", "not_in_channel"}: + raise + return + raise ValueError("Slack omitted an accessible prior channel; retry enumeration") + + async def _confirm_message_omission(self, record: SourceRecord) -> None: + """An exact read can disprove omission; an empty read does not prove deletion.""" + message = await self._read_known_message(record) + if message is not None: + raise ValueError("Slack omitted an accessible prior message; retry enumeration") + raise ValueError("Slack message omission remains unconfirmed; capture is incomplete") + + async def _read_known_message(self, record: SourceRecord) -> dict[str, JsonValue] | None: + """Use native timestamp bounds and the retained thread route for one exact owner.""" + identity = record.identity + if ( + identity.container_id is None + or record.parent + != RecordIdentity(record_type="channel", native_id=identity.container_id) + or record.payload.get("ts") != identity.native_id + ): + raise ValueError("Slack message omission requires its retained channel identity") + thread_ts = record.payload.get("thread_ts") + if thread_ts is not None and (not isinstance(thread_ts, str) or not thread_ts): + raise ValueError("Slack retained message has invalid thread identity") + params = { + "channel": identity.container_id, + "oldest": identity.native_id, + "latest": identity.native_id, + "inclusive": "true", + "limit": 1, + } + operation = "conversations.history" + if thread_ts and thread_ts != identity.native_id: + operation = "conversations.replies" + params["ts"] = thread_ts + payload = await self._get(f"https://slack.com/api/{operation}", params) + messages = TypeAdapter(list[dict[str, JsonValue]]).validate_python(payload.get("messages")) + matches = [message for message in messages if message.get("ts") == identity.native_id] + if len(matches) > 1: + raise ValueError("Slack exact message read returned duplicate identities") + if not matches: + return None + message = matches[0] + if message.get("thread_ts", identity.native_id) != (thread_ts or identity.native_id): + raise ValueError("Slack exact message read changed its retained thread identity") + return message # ------------------------------------------------------------------ # Federated search @@ -292,5 +808,17 @@ async def generate_entities( yield # make this a generator # noqa: RUF027 async def validate(self) -> None: - """Validate credentials by calling Slack auth.test.""" - await self._get("https://slack.com/api/auth.test") + """Re-attest the exact bound workspace/user, clearing old evidence first.""" + self._verified_principal = None + raw = await self._get("https://slack.com/api/auth.test") + try: + principal = SlackPrincipal.model_validate(raw) + except ValidationError: + raise ValueError("Slack returned an invalid native identity") from None + if self.slack_config is not None and self.slack_config.expected_team_id is not None: + if ( + principal.team_id != self.slack_config.expected_team_id + or principal.user_id != self.slack_config.expected_user_id + ): + raise ValueError("Slack identity does not match the trusted binding") + self._verified_principal = principal diff --git a/backend/airweave/platform/sources/slack_content.py b/backend/airweave/platform/sources/slack_content.py new file mode 100644 index 0000000000..70ffab8aa4 --- /dev/null +++ b/backend/airweave/platform/sources/slack_content.py @@ -0,0 +1,177 @@ +"""Acquire Slack originals before the existing page transaction advances its cursor.""" + +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from airweave.domains.entities.canonical.requests import BlobReference, CaptureRecord +from airweave.domains.entities.canonical.slack_files import ( + SlackFileManifest, + SlackFileOutcome, + SlackFileReason, +) +from airweave.domains.sources.exceptions import ( + SourceEntityForbiddenError, + SourceEntityNotFoundError, + SourceServerError, +) +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.slack_errors import SlackApiError + +if TYPE_CHECKING: + from airweave.platform.sources.slack import SlackSource + + +class SlackFileMetadata(BaseModel): + """Parse only acquisition fields; original native JSON remains authoritative.""" + + model_config = ConfigDict(extra="ignore", strict=True) + id: str = Field(pattern=r"^F[A-Z0-9]+$", max_length=128) + name: str | None = None + size: int | None = Field(default=None, ge=0) + mimetype: str | None = Field(default=None, max_length=256) + is_external: bool = False + mode: str | None = None + url_private_download: str | None = Field(default=None, max_length=16384) + url_private: str | None = Field(default=None, max_length=16384) + + +class SlackMessageFiles(BaseModel): + """Malformed file lists fail capture instead of quietly dropping originals.""" + + model_config = ConfigDict(extra="ignore", strict=True) + files: list[SlackFileMetadata] = Field(default_factory=list, max_length=100) + + +def _original_url(file: SlackFileMetadata) -> str | None: + url = file.url_private_download or file.url_private + if not url: + return None + try: + parsed = urlsplit(url) + valid = ( + parsed.scheme == "https" + and parsed.hostname in {"slack.com", "files.slack.com"} + and parsed.port in {None, 443} + and parsed.username is None + and parsed.password is None + and not parsed.fragment + ) + except ValueError: + valid = False + if not valid: + raise ValueError("Slack original URL is outside the permitted origin") + return url + + +async def capture_slack_file( + source: "SlackSource", record: CaptureRecord, files: FileService +) -> CaptureRecord: + """Acquire one message-owned file; the caller commits its child page atomically.""" + file = SlackFileMetadata.model_validate(record.payload) + if record.identity.record_type != "file" or record.identity.native_id != file.id: + raise ValueError("Slack file payload does not match its record identity") + file, native, reason = await _enrich(source, file) + blobs = [] + if reason is None: + if file.is_external or file.mode == "external": + reason = "unsupported_external" + elif file.size is not None and file.size > files.MAX_FILE_SIZE_BYTES: + reason = "oversized" + elif file.size is None or not file.mimetype or (url := _original_url(file)) is None: + reason = "missing_metadata" + else: + blob, reason = await _download(source, file, url, files) + if blob is not None: + blobs.append(blob.model_copy(update={"source_path": ""})) + manifest = SlackFileManifest( + files=( + SlackFileOutcome( + index=0, + native_id=file.id, + outcome="captured" if reason is None else "unavailable", + reason=reason, + file=native, + ), + ) + ) + reference = await files.store_canonical_blob( + manifest.model_dump_json().encode(), media_type="application/json" + ) + blobs.append(reference.model_copy(update={"role": "representation_manifest"})) + return record.model_copy( + update={ + "blobs": tuple(blobs), + "completeness": "partial" if reason else "complete", + } + ) + + +async def _download( + source: "SlackSource", file: SlackFileMetadata, url: str, files: FileService +) -> tuple[BlobReference | None, SlackFileReason | None]: + try: + blob = await files.capture_canonical_url( + url, + source.http_client, + source.auth, + source.logger, + media_type=file.mimetype, + expected_media_type=file.mimetype, + follow_redirects=False, + ) + except FileSkippedException: + return None, "oversized" + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {403, 404}: + return None, "access_denied" if exc.response.status_code == 403 else "not_found" + # Never put private download URLs or response bodies in domain diagnostics. + safe = httpx.Response( + exc.response.status_code, + headers={"Retry-After": exc.response.headers["Retry-After"]} + if "Retry-After" in exc.response.headers + else {}, + request=httpx.Request("GET", "https://slack.com/"), + ) + raise_for_status( + safe, source_short_name="slack", token_provider_kind=source.auth.provider_kind + ) + raise + except httpx.RequestError: + raise SourceServerError( + "Slack original download failed", source_short_name="slack" + ) from None + if file.size is not None and blob.size_bytes != file.size: + raise ValueError("Slack original size changed during capture; retry the page") + return blob.model_copy(update={"filename": file.name or None}), None + + +async def _enrich( + source: "SlackSource", file: SlackFileMetadata +) -> tuple[SlackFileMetadata, dict[str, JsonValue] | None, SlackFileReason | None]: + if file.is_external or file.mode == "external": + return file, None, None + if _original_url(file) and file.size is not None and file.mimetype: + return file, None, None + try: + response = await source._get("https://slack.com/api/files.info", {"file": file.id}) + except SourceEntityForbiddenError: + return file, None, "access_denied" + except SourceEntityNotFoundError: + return file, None, "not_found" + except SlackApiError as exc: + if exc.code in {"access_denied", "not_visible"}: + return file, None, "access_denied" + if exc.code in {"file_not_found", "file_deleted"}: + return file, None, "not_found" + # Missing scope is a connection/configuration failure, not evidence of file denial. + raise + native = response.get("file") + enriched = SlackFileMetadata.model_validate(native) + if enriched.id != file.id: + raise ValueError("Slack file metadata identity changed") + return enriched, native, None diff --git a/backend/airweave/platform/sources/slack_errors.py b/backend/airweave/platform/sources/slack_errors.py new file mode 100644 index 0000000000..fd2ce8a6b7 --- /dev/null +++ b/backend/airweave/platform/sources/slack_errors.py @@ -0,0 +1,10 @@ +"""Native Slack application error codes shared by message and file acquisition.""" + + +class SlackApiError(ValueError): + """A native application failure cannot imply a successful empty page.""" + + def __init__(self, code: str): + """Expose the bounded provider code to explicit access interpretation.""" + self.code = code + super().__init__(f"Slack request failed: {code}") diff --git a/backend/airweave/platform/sources/slite.py b/backend/airweave/platform/sources/slite.py index 90e38011ba..140ce3cc91 100644 --- a/backend/airweave/platform/sources/slite.py +++ b/backend/airweave/platform/sources/slite.py @@ -22,12 +22,12 @@ from airweave.platform.decorators import source from airweave.platform.entities.slite import SliteNoteEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod SLITE_API_BASE = "https://api.slite.com/v1" diff --git a/backend/airweave/platform/sources/stripe.py b/backend/airweave/platform/sources/stripe.py index ae57060373..c172c41748 100644 --- a/backend/airweave/platform/sources/stripe.py +++ b/backend/airweave/platform/sources/stripe.py @@ -19,15 +19,27 @@ from __future__ import annotations import base64 +import json +from contextlib import aclosing from datetime import datetime +from functools import partial from typing import AsyncGenerator, Optional -from tenacity import retry, stop_after_attempt +import httpx +from pydantic import JsonValue +from tenacity import retry, retry_if_exception_type, stop_after_attempt from airweave.core.logging import ContextualLogger from airweave.core.shared_models import RateLimitLevel +from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError from airweave.domains.browse_tree.types import NodeSelectionData -from airweave.domains.sources.token_providers.protocol import AuthProviderKind, SourceAuthProvider +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.protocol import ( + AuthProviderKind, + ManagedAuthProvider, + SourceAuthProvider, + authorization_headers, +) from airweave.domains.storage.file_service import FileService from airweave.domains.syncs.cursors.cursor import SyncCursor from airweave.platform.configs.auth import StripeAuthConfig @@ -49,12 +61,13 @@ _parse_stripe_ts, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.stripe_capture import StripeCapture from airweave.schemas.source_connection import AuthenticationMethod _parse_unix_timestamp = _parse_stripe_ts @@ -80,6 +93,9 @@ class StripeSource(BaseSource): including transactions, customers, subscriptions, and account analytics. """ + canonical_record_types = StripeCapture.canonical_record_types + MAX_CAPTURE_RESPONSE_BYTES = 16 * 1024 * 1024 + @classmethod async def create( cls, @@ -91,12 +107,76 @@ async def create( ) -> StripeSource: """Create a new Stripe source instance.""" instance = cls(auth=auth, logger=logger, http_client=http_client) + if config.original_capture is not None: + instance._capture_page_source = await StripeCapture.create( + instance._read_original, config.original_capture + ) + return instance + if isinstance(auth, ManagedAuthProvider): + raise SourceError( + "Managed Stripe requires an explicitly bound original capture configuration", + source_short_name="stripe", + ) if auth.provider_kind == AuthProviderKind.CREDENTIAL: instance._api_key = auth.credentials.api_key else: instance._api_key = await auth.get_token() return instance + @retry( + stop=stop_after_attempt(5), + retry=retry_if_rate_limit_or_timeout | retry_if_exception_type(AuthProviderRateLimitError), + wait=partial(wait_rate_limit_with_backoff, max_rate_limit_wait=None), + reraise=True, + ) + async def _read_original( + self, path: str, *, params: dict[str, str | int], headers: dict[str, str] + ) -> dict[str, JsonValue]: + """Read the fixed Stripe origin using the existing managed/direct transport.""" + if not path.startswith("/v1/") or "?" in path or "#" in path or ".." in path: + raise SourceError("Invalid Stripe capture path", source_short_name="stripe") + if self.auth.provider_kind == AuthProviderKind.CREDENTIAL: + credentials = StripeAuthConfig.model_validate(self.auth.credentials.model_dump()) + auth_headers = {"Authorization": f"Bearer {credentials.api_key}"} + else: + auth_headers = await authorization_headers(self.auth) + async with self.http_client.stream( + "GET", + "https://api.stripe.com" + path, + params=params, + headers={**headers, **auth_headers}, + timeout=20.0, + follow_redirects=False, + ) as response: + if not response.is_success: + # Do not read provider error bodies, which may echo private fields. + safe_response = httpx.Response( + response.status_code, + headers={k: v for k, v in response.headers.items() if k == "retry-after"}, + request=httpx.Request("GET", "https://api.stripe.com/v1/"), + ) + raise_for_status( + safe_response, + source_short_name="stripe", + token_provider_kind=self.auth.provider_kind, + ) + content = bytearray() + async with aclosing(response.aiter_bytes(chunk_size=64 * 1024)) as chunks: + async for chunk in chunks: + if len(content) + len(chunk) > self.MAX_CAPTURE_RESPONSE_BYTES: + raise SourceError( + "Stripe capture response exceeds the 16 MiB decoded-body limit; " + "the page was not committed", + source_short_name="stripe", + ) + content.extend(chunk) + try: + return json.loads(content) + except (ValueError, UnicodeError): + raise SourceError( + "Stripe returned invalid JSON", source_short_name="stripe" + ) from None + @retry( stop=stop_after_attempt(5), retry=retry_if_rate_limit_or_timeout, @@ -551,4 +631,7 @@ async def generate_entities( # noqa: C901 async def validate(self) -> None: """Verify Stripe API key by pinging a lightweight endpoint (/v1/balance).""" + if self._capture_page_source is not None: + await self._capture_page_source.prepare_cycle(None) + return await self._get("https://api.stripe.com/v1/balance") diff --git a/backend/airweave/platform/sources/stripe_capture.py b/backend/airweave/platform/sources/stripe_capture.py new file mode 100644 index 0000000000..85f275dc11 --- /dev/null +++ b/backend/airweave/platform/sources/stripe_capture.py @@ -0,0 +1,276 @@ +"""Account-bound Stripe v1 originals; full discovery, never absence reconciliation. + +The caller owns credentials/HTTP errors and must apply every supplied header. No +activation is implied by constructing this adapter. Nested collections and linked +files are retained as returned, not claimed to be fully acquired. +""" + +import hashlib +from datetime import datetime, timezone +from typing import Annotated, Literal, Protocol + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + StrictBool, + TypeAdapter, + ValidationError, +) + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import CapturePage, CapturePlan +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.storage.file_service import FileService +from airweave.platform.configs.config import StripeCaptureConfig + +NativeID = Annotated[str, Field(pattern=r"^[A-Za-z0-9_]{1,255}$")] +_JSON = TypeAdapter(dict[str, JsonValue]) +_ENDPOINTS = { + "balance": "balance", + "balance_transaction": "balance_transactions", + "charge": "charges", + "customer": "customers", + "event": "events", + "invoice": "invoices", + "payment_intent": "payment_intents", + "payment_method": "payment_methods", + "payout": "payouts", + "refund": "refunds", + "subscription": "subscriptions", +} + + +class StripeCaptureError(ValueError): + """Sanitized boundary failure; the current page must not commit.""" + + +class StripeRead(Protocol): + """Authenticated transport; fixed relative v1 paths and no provider URL following.""" + + async def __call__( + self, path: str, *, params: dict[str, str | int], headers: dict[str, str] + ) -> dict[str, JsonValue]: + """Raise existing auth/rate/transport errors; return only successful native JSON.""" + ... + + +class _Account(BaseModel): + id: NativeID + object: Literal["account"] + + +class _Balance(BaseModel): + object: Literal["balance"] + livemode: StrictBool + + +class _Object(BaseModel): + id: NativeID + object: str + livemode: StrictBool | None = None + created: Annotated[int, Field(strict=True, ge=0, le=253402300799)] | None = None + + +class _Page(BaseModel): + object: Literal["list"] + data: list[dict[str, JsonValue]] = Field(max_length=100) + has_more: StrictBool + + +class _Continuation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + fingerprint: str + record_type: str + starting_after: NativeID | None = None + done: StrictBool = False + + +class StripeCapture: + """Eleven independent account-owned roots, preserving legacy resource breadth. + + Payment methods use the unfiltered list (custom types are excluded by Stripe). + Subscriptions request all statuses; test-clock inventories and full event history + remain unqualified. Older API versions rejecting unfiltered lists fail explicitly; + no card-only fallback silently narrows capture. + """ + + canonical_record_types = tuple(_ENDPOINTS) + canonical_container_parents: dict[str, str] = {} + + def __init__(self, read: StripeRead, config: StripeCaptureConfig) -> None: + """Use create to attest access before exposing capture configuration.""" + self._read = read + self.config = config + self._verified = False + self.fingerprint = hashlib.sha256( + ("stripe-full-v2:" + config.model_dump_json()).encode() + ).hexdigest() + + @classmethod + async def create(cls, read: StripeRead, config: StripeCaptureConfig) -> "StripeCapture": + """Attest the effective account and mode through the capture request context.""" + instance = cls(read, config) + await instance.verify_principal() + return instance + + async def _get(self, path: str, params: dict[str, str | int]) -> dict[str, JsonValue]: + headers = {"Stripe-Version": self.config.api_version} + if self.config.connected_account_id is not None: + headers["Stripe-Account"] = self.config.connected_account_id + raw = await self._read(path, params=params, headers=headers) + try: + return _JSON.validate_python(raw) + except ValidationError: + raise StripeCaptureError("Stripe returned invalid native JSON") from None + + async def verify_principal(self) -> None: + """Reset prior attestation before any validation/reconnect attempt.""" + self._verified = False + try: + account = _Account.model_validate(await self._get("/v1/account", {})) + if account.id != self.config.expected_account_id or ( + self.config.connected_account_id is not None + and account.id != self.config.connected_account_id + ): + raise StripeCaptureError("Stripe account does not match trusted binding") + balance = _Balance.model_validate(await self._get("/v1/balance", {})) + if balance.livemode != self.config.livemode: + raise StripeCaptureError("Stripe mode does not match trusted binding") + except ValidationError: + raise StripeCaptureError("Stripe identity response is invalid") from None + self._verified = True + + def _require_principal(self) -> None: + if not self._verified: + raise StripeCaptureError("Stripe principal is not attested") + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Completed list traversal never proves deletion or exhaustive provider coverage.""" + self._require_principal() + return CycleConfiguration.from_source( + fingerprint=self.fingerprint, + record_types=self.canonical_record_types, + container_parents={}, + completion_policies={kind: "discovery_only" for kind in self.canonical_record_types}, + ) + + async def prepare_cycle(self, previous: CaptureCycle | None) -> CapturePlan: + """Re-attest each full cycle; event capture is not an incremental checkpoint.""" + await self.verify_principal() + return CapturePlan() + + def initial_continuation(self, cycle: CaptureCycle) -> ScanContinuation: + """The engine binds cycle configuration; per-kind cursor binding occurs on first page.""" + if cycle.configuration != self.capture_cycle_configuration: + raise StripeCaptureError("Stripe capture configuration changed") + return ScanContinuation() + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Relationships to other Stripe objects are references, not visibility parents.""" + raise StripeCaptureError("Stripe capture has no child scopes") + + async def confirm_absent(self, record: SourceRecord) -> None: + """Aging events, list omission and generic HTTP errors never establish deletion.""" + raise StripeCaptureError("Stripe discovery cannot confirm absence") + + def _record(self, raw: dict[str, JsonValue], kind: str) -> CaptureRecord: + if kind == "balance": + balance = _Balance.model_validate(raw) + native_id, mode, created = "balance", balance.livemode, None + else: + obj = _Object.model_validate(raw) + if "livemode" in raw and obj.livemode is None: + raise StripeCaptureError("Stripe object mode is invalid") + # Stripe documents object as the kind discriminator; ID examples are + # not an exhaustive prefix contract (including historical IDs). + if obj.object != kind: + raise StripeCaptureError("Stripe object kind does not match scope") + native_id, mode = obj.id, obj.livemode + created = ( + datetime.fromtimestamp(obj.created, timezone.utc) + if obj.created is not None + else None + ) + if mode is not None and mode != self.config.livemode: + raise StripeCaptureError("Stripe object mode does not match source") + return CaptureRecord( + identity=RecordIdentity(record_type=kind, native_id=native_id), + payload=raw, + completeness="partial", + source_created_at=created, + observed_at=datetime.now(timezone.utc), + ) + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """One list response and its exact cursor are committed by the shared engine.""" + self._require_principal() + kind = scope.record_type + if ( + kind not in _ENDPOINTS + or scope.container_id is not None + or scope.parent is not None + or parent is not None + ): + raise StripeCaptureError("Unsupported Stripe capture scope") + try: + progress = ( + _Continuation.model_validate(continuation.value) + if continuation.value + else _Continuation(fingerprint=self.fingerprint, record_type=kind) + ) + if ( + progress.fingerprint != self.fingerprint + or progress.record_type != kind + or progress.done + ): + raise StripeCaptureError("Stripe continuation binding is invalid") + params: dict[str, str | int] = {} if kind == "balance" else {"limit": 100} + if progress.starting_after is not None: + if kind == "balance": + raise StripeCaptureError("Stripe balance cannot be paginated") + params["starting_after"] = progress.starting_after + if kind == "subscription": + params["status"] = "all" + raw = await self._get("/v1/" + _ENDPOINTS[kind], params) + if kind == "balance": + objects, has_more = [raw], False + else: + page = _Page.model_validate(raw) + objects, has_more = page.data, page.has_more + records = tuple(self._record(item, kind) for item in objects) + ids = [record.identity.native_id for record in records] + if ( + len(set(ids)) != len(ids) + or progress.starting_after in ids + or (has_more and not ids) + ): + raise StripeCaptureError("Stripe pagination did not make progress") + following = _Continuation( + fingerprint=self.fingerprint, + record_type=kind, + starting_after=ids[-1] if has_more else None, + done=not has_more, + ) + return CapturePage( + records=records, + continuation=ScanContinuation(value=following.model_dump(mode="json")), + final=not has_more, + ) + except (ValidationError, OverflowError, OSError): + raise StripeCaptureError("Stripe capture response or continuation is invalid") from None diff --git a/backend/airweave/platform/sources/teams.py b/backend/airweave/platform/sources/teams.py index 4ec2cf7074..bb23aa3342 100644 --- a/backend/airweave/platform/sources/teams.py +++ b/backend/airweave/platform/sources/teams.py @@ -39,12 +39,12 @@ _parse_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/tests/test_calendar_identity.py b/backend/airweave/platform/sources/tests/test_calendar_identity.py new file mode 100644 index 0000000000..8e597a9b83 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_calendar_identity.py @@ -0,0 +1,67 @@ +"""Native primary resource attestation gates capture independently of lifecycle wiring.""" + +from unittest.mock import MagicMock + +import httpx +import pytest + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.sources.google_calendar import GoogleCalendarSource + + +@pytest.mark.asyncio +async def test_principal_change_invalidates_attestation_and_capture_identity(): + native_id = "opaque-primary-id" + + def profile(request): + assert request.url.path.endswith("/calendars/primary") + return httpx.Response(200, json={"id": native_id}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(profile)) as client: + source = await GoogleCalendarSource.create( + auth=StaticTokenProvider("same-broker-account"), + logger=MagicMock(), + http_client=client, + config=GoogleCalendarConfig(expected_primary_calendar_id=native_id), + ) + fingerprint = source.capture_cycle_configuration.fingerprint + native_id = "different-primary" + with pytest.raises(ValueError, match="does not match"): + await source.validate() + with pytest.raises(ValueError, match="attested primary"): + await source.capture_page( + CompletedScope(record_type="calendar"), + ScanContinuation(value={}), + files=MagicMock(), + ) + source.calendar_config = GoogleCalendarConfig(expected_primary_calendar_id=native_id) + await source.validate() + assert source.capture_cycle_configuration.fingerprint != fingerprint + native_id = "private invalid value" + with pytest.raises(ValueError, match="invalid primary identity") as error: + await source.validate() + assert native_id not in str(error.value) + + +@pytest.mark.asyncio +async def test_unbound_legacy_source_cannot_start_or_resume_owned_capture(): + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: pytest.fail("Unexpected native call")) + ) as client: + source = await GoogleCalendarSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GoogleCalendarConfig(), + ) + with pytest.raises(ValueError, match="attested primary"): + await source.prepare_cycle(None) + with pytest.raises(ValueError, match="attested primary"): + await source.capture_page( + CompletedScope(record_type="calendar"), + ScanContinuation(value={}), + files=MagicMock(), + ) diff --git a/backend/airweave/platform/sources/tests/test_drive_identity.py b/backend/airweave/platform/sources/tests/test_drive_identity.py new file mode 100644 index 0000000000..2e76066990 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_drive_identity.py @@ -0,0 +1,95 @@ +"""Native Drive principal attestation precedes every owned capture entrypoint.""" + +from unittest.mock import MagicMock + +import httpx +import pytest + +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GoogleDriveConfig +from airweave.platform.sources.google_drive import GoogleDriveSource + + +async def connector(client, expected="principal-a"): + return await GoogleDriveSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GoogleDriveConfig(expected_permission_id=expected), + ) + + +@pytest.mark.parametrize( + "user", + [ + {"permissionId": "different", "me": True}, + {"permissionId": "principal-a", "me": False}, + {"permissionId": "principal-a", "me": "true"}, + {"permissionId": 123, "me": True}, + {"permissionId": "", "me": True}, + {"permissionId": "principal-a"}, + None, + ], +) +async def test_invalid_principal_never_reaches_capture(user): + calls = [] + + def respond(request): + calls.append(request.url.path) + assert request.url.params["fields"] == "user(permissionId,me)" + return httpx.Response(200, json={"user": user}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + with pytest.raises(ValueError) as error: + await connector(client) + assert calls == ["/drive/v3/about"] + assert "principal-a" not in str(error.value) and "different" not in str(error.value) + + +async def test_revalidation_failure_revokes_direct_capture_and_resume(): + replies = ["principal-a", "different"] + + def respond(request): + assert request.url.path == "/drive/v3/about" + return httpx.Response(200, json={"user": {"permissionId": replies.pop(0), "me": True}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + source = await connector(client) + original = source.capture_cycle_configuration + with pytest.raises(ValueError, match="trusted binding"): + await source.validate() + with pytest.raises(ValueError, match="attested"): + _ = source.capture_cycle_configuration + with pytest.raises(ValueError, match="attested"): + await source.prepare_cycle(None) + with pytest.raises(ValueError, match="attested"): + source.initial_continuation(MagicMock(configuration=original)) + with pytest.raises(ValueError, match="attested"): + await source.capture_page(None, None, files=MagicMock()) + with pytest.raises(ValueError, match="attested"): + await source.refresh_known(None, files=MagicMock()) + assert not replies + + +async def test_legacy_creation_is_allowed_but_canonical_capture_requires_binding(): + def respond(request): + raise AssertionError("Legacy creation should not require a new permission binding") + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + source = await connector(client, None) + with pytest.raises(ValueError, match="attested"): + await source.prepare_cycle(None) + + +async def test_fingerprint_and_resume_are_principal_bound(): + principals = iter(["principal-a", "principal-b"]) + + def respond(request): + return httpx.Response(200, json={"user": {"permissionId": next(principals), "me": True}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + first = await connector(client) + second = await connector(client, "principal-b") + assert first.capture_cycle_configuration != second.capture_cycle_configuration + with pytest.raises(ValueError, match="trusted capture configuration"): + second.initial_continuation(MagicMock(configuration=first.capture_cycle_configuration)) diff --git a/backend/airweave/platform/sources/tests/test_gmail_capture.py b/backend/airweave/platform/sources/tests/test_gmail_capture.py new file mode 100644 index 0000000000..ac72942306 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_gmail_capture.py @@ -0,0 +1,522 @@ +"""Gmail native pages and MIME retention; SQL recovery is tested separately.""" + +from uuid import uuid4 + +import httpx +import pytest + +from airweave.domains.entities.canonical.cycle_models import ( + CaptureCycle, + CycleConfiguration, + CycleVersion, +) +from airweave.domains.entities.canonical.page_source import ( + InvalidCaptureCheckpoint, + InvalidScanContinuation, +) +from airweave.platform.sources.gmail_capture import BASE, GmailCapture +from airweave.platform.sources.gmail_pages import GmailPages + + +def message(mid, **extra): + return {"id": mid, "threadId": "t", "internalDate": "1000", "payload": {}, **extra} + + +def missing(): + response = httpx.Response(404, request=httpx.Request("GET", BASE)) + return httpx.HTTPStatusError("Gone", request=response.request, response=response) + + +class Provider: + def __init__(self, steps): + self.steps = list(steps) + self.calls = [] + + async def get(self, url, params=None): + self.calls.append((url, params)) + expected, response = self.steps.pop(0) + assert url == BASE + expected + if isinstance(response, Exception): + raise response + return response + + +async def initial(pages): + config = CycleConfiguration(fingerprint="a" * 64, parents={"message": (None,)}) + plan = await pages.prepare(None, config) + cycle = CaptureCycle( + version=CycleVersion(cycle_id=uuid4(), revision=1), + configuration=config, + mode=plan.mode, + starting_checkpoint=plan.starting_checkpoint, + ) + return pages.initial(cycle) + + +@pytest.mark.asyncio +async def test_baseline_interleaves_history_before_next_list_page(): + provider = Provider( + [ + ("/profile", {"historyId": "10"}), + ("/messages", {"messages": [{"id": "a"}], "nextPageToken": "p2"}), + ("/messages/a", message("a")), + ( + "/history", + {"historyId": "11", "history": [{"labelsRemoved": [{"message": {"id": "a"}}]}]}, + ), + ("/messages/a", message("a", labelIds=[])), + ("/messages", {"messages": [{"id": "b"}]}), + ("/messages/b", message("b")), + ( + "/history", + {"historyId": "12", "history": [{"messagesAdded": [{"message": {"id": "c"}}]}]}, + ), + ("/messages/c", message("c")), + ] + ) + pages = GmailPages(GmailCapture(provider.get, None)) + continuation = await initial(pages) + results = [] + for _ in range(4): + page = await pages.page(continuation) + results.append(page) + continuation = page.continuation + assert [r.identity.native_id for p in results for r in p.records] == ["a", "a", "b", "c"] + assert [p.final for p in results] == [False, False, False, True] + assert all(p.provider_checkpoint is None for p in results[:-1]) + assert results[-1].provider_checkpoint.value == {"history_id": "12"} + assert provider.calls[3][1]["startHistoryId"] == "10" + assert provider.calls[5][1]["pageToken"] == "p2" + assert provider.calls[7][1]["startHistoryId"] == "11" + assert provider.calls[1][1]["includeSpamTrash"] == "true" + assert "q" not in provider.calls[1][1] + + +@pytest.mark.asyncio +async def test_filtered_query_is_list_only_without_provider_checkpoint(): + provider = Provider([("/messages", {"messages": [{"id": "a"}]}), ("/messages/a", message("a"))]) + pages = GmailPages(GmailCapture(provider.get, "in:inbox")) + page = await pages.page(await initial(pages)) + assert page.final and page.provider_checkpoint is None + assert provider.calls[0][1]["q"] == "in:inbox" + assert not provider.steps + + +@pytest.mark.asyncio +async def test_expired_history_requires_whole_cycle_recovery(): + provider = Provider( + [("/profile", {"historyId": "10"}), ("/messages", {}), ("/history", missing())] + ) + pages = GmailPages(GmailCapture(provider.get, None)) + first = await pages.page(await initial(pages)) + with pytest.raises(InvalidCaptureCheckpoint): + await pages.page(first.continuation) + assert first.provider_checkpoint is None + + +@pytest.mark.asyncio +async def test_history_replay_hydrates_bounded_offsets_and_rejects_changed_page(): + raw = {"historyId": "11", "history": [{"messages": [{"id": str(i)} for i in range(51)]}]} + steps = [("/profile", {"historyId": "10"}), ("/messages", {}), ("/history", raw)] + steps.extend((f"/messages/{i}", message(str(i))) for i in range(50)) + steps.append(("/history", {**raw, "historyId": "12"})) + provider = Provider(steps) + pages = GmailPages(GmailCapture(provider.get, None)) + listing = await pages.page(await initial(pages)) + half = await pages.page(listing.continuation) + assert len(half.records) == 50 and not half.final and half.provider_checkpoint is None + assert half.continuation.value["history_offset"] == 50 + with pytest.raises(InvalidScanContinuation): + await pages.page(half.continuation) + assert provider.calls[-1][0].endswith("/history") + + +@pytest.mark.asyncio +async def test_history_pagination_keeps_start_boundary_until_terminal(): + provider = Provider( + [ + ("/profile", {"historyId": "10"}), + ("/messages", {}), + ("/history", {"historyId": "999", "nextPageToken": "next"}), + ("/history", {"historyId": "1000"}), + ] + ) + pages = GmailPages(GmailCapture(provider.get, None)) + listing = await pages.page(await initial(pages)) + intermediate = await pages.page(listing.continuation) + assert intermediate.continuation.value["history_boundary"] == "10" + assert intermediate.provider_checkpoint is None + terminal = await pages.page(intermediate.continuation) + assert provider.calls[-1][1]["startHistoryId"] == "10" + assert terminal.provider_checkpoint.value == {"history_id": "1000"} + + +@pytest.mark.asyncio +async def test_repeated_listing_cursor_fails_without_a_completed_page(): + provider = Provider( + [("/messages", {"nextPageToken": "same"}), ("/messages", {"nextPageToken": "same"})] + ) + pages = GmailPages(GmailCapture(provider.get, "in:inbox")) + first = await pages.page(await initial(pages)) + with pytest.raises(ValueError, match="repeated"): + await pages.page(first.continuation) + assert not first.final + + +@pytest.mark.asyncio +async def test_external_mime_bytes_are_stored_before_complete_record_without_payload_mutation(): + from unittest.mock import AsyncMock + + from airweave.domains.storage.file_service import FileService + + storage = AsyncMock() + files = FileService(uuid4(), storage, sync_id=uuid4()) + raw = message( + "a", + payload={ + "parts": [ + { + "mimeType": "text/plain", + "filename": "नोट्स.txt", + "body": { + "attachmentId": "body-id", + "size": 3, + }, + } + ] + }, + ) + provider = Provider([("/messages/a", raw)]) + attachment_get = AsyncMock(return_value={"data": "YWJj", "size": 3}) + record = await GmailCapture( + provider.get, None, files=files, attachment_get=attachment_get + ).message("a") + assert record.completeness == "complete" + assert record.payload == raw + assert record.blobs[0].source_path == "/payload/parts/0/body" + assert record.blobs[0].filename == "नोट्स.txt" + assert record.blobs[0].size_bytes == 3 + storage.write_file.assert_awaited_once_with(record.blobs[0].key, b"abc") + + +@pytest.mark.asyncio +async def test_blob_storage_failure_propagates_and_prevents_record_checkpoint(): + from unittest.mock import AsyncMock + + from airweave.domains.storage.file_service import FileService + + storage = AsyncMock() + storage.write_file.side_effect = OSError("storage unavailable") + files = FileService(uuid4(), storage, sync_id=uuid4()) + provider = Provider( + [ + ("/history", {"historyId": "21", "history": [{"messages": [{"id": "a"}]}]}), + ("/messages/a", message("a", payload={"body": {"attachmentId": "body-id", "size": 3}})), + ] + ) + attachment_get = AsyncMock(return_value={"data": "YWJj", "size": 3}) + capture = GmailCapture(provider.get, None, files=files, attachment_get=attachment_get) + page = await capture.history_page("20") + with pytest.raises(OSError): + await capture.hydrate_history_page(page) + + +@pytest.mark.asyncio +async def test_oversize_declared_mime_is_partial_without_fetching_or_writing(): + from unittest.mock import AsyncMock + + from airweave.domains.storage.file_service import FileService + + storage = AsyncMock() + files = FileService(uuid4(), storage, sync_id=uuid4()) + files.MAX_FILE_SIZE_BYTES = 2 + provider = Provider( + [ + ( + "/messages/a", + message( + "a", + payload={ + "body": { + "attachmentId": "body-id", + "size": 3, + } + }, + ), + ) + ] + ) + attachment_get = AsyncMock() + record = await GmailCapture( + provider.get, None, files=files, attachment_get=attachment_get + ).message("a") + assert record.completeness == "partial" + attachment_get.assert_not_called() + storage.write_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_mime_size_mismatch_does_not_store_unverified_bytes(): + from unittest.mock import AsyncMock + + from airweave.domains.storage.file_service import FileService + + storage = AsyncMock() + files = FileService(uuid4(), storage, sync_id=uuid4()) + provider = Provider( + [ + ( + "/messages/a", + message( + "a", + payload={ + "body": { + "attachmentId": "body-id", + "size": 4, + } + }, + ), + ) + ] + ) + attachment_get = AsyncMock(return_value={"data": "YWJj", "size": 3}) + with pytest.raises(ValueError, match="size"): + await GmailCapture(provider.get, None, files=files, attachment_get=attachment_get).message( + "a" + ) + storage.write_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_mime_json_stream_size_limit_closes_response(): + import logging + + from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider + from airweave.domains.storage.exceptions import FileSkippedException + from airweave.platform.configs.config import GmailConfig + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + from airweave.platform.sources.gmail import GmailSource + + response = httpx.Response(200, json={"data": "YWJj", "size": 3}) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: response)) as client: + source = await GmailSource.create( + auth=ManagedAuthProvider( + api_key="not-used", + connected_account_id="test", + allowed_hosts=frozenset({"gmail.googleapis.com"}), + ), + logger=logging.getLogger("test"), + http_client=AirweaveHttpClient(client, uuid4(), "gmail", feature_flag_enabled=False), + config=GmailConfig(), + ) + with pytest.raises(FileSkippedException): + await source._get_mime_json(BASE + "/messages/a/attachments/b", max_bytes=5) + assert response.is_closed + + +@pytest.mark.asyncio +async def test_large_originals_split_batch_without_relisting_or_truncation(): + raw = message("large", payload={"body": {"data": "x" * (9 * 1024 * 1024)}}) + provider = Provider( + [ + ("/messages", {"messages": [{"id": "large"}, {"id": "small"}]}), + ("/messages/large", raw), + ("/messages/small", message("small")), + ] + ) + pages = GmailPages(GmailCapture(provider.get, "in:inbox")) + first = await pages.page(await initial(pages)) + assert not first.final and first.records[0].payload == raw + assert first.continuation.value["pending_ids"] == ["small"] + # A new adapter instance models loss of all in-process state. + final = await GmailPages(GmailCapture(provider.get, "in:inbox")).page(first.continuation) + assert final.final and final.records[0].identity.native_id == "small" + assert len([url for url, _ in provider.calls if url.endswith("/messages")]) == 1 + + +def test_combined_cursor_rings_fit_durable_continuation_and_recent_loops_fail(): + import hashlib + + from airweave.platform.sources.gmail_pages import _next_token, _Progress + + values = tuple(hashlib.sha256(str(i).encode()).hexdigest() for i in range(128)) + state = _Progress( + mode="full", + phase="history", + history_boundary="h" * 1024, + list_token="l" * 8192, + history_token="h" * 8192, + list_tokens=values, + history_tokens=values, + ) + assert len(state.continuation().model_dump_json().encode()) < 65536 + rotated = values + for i in range(128, 1000): + rotated = _next_token(str(i), rotated) + assert len(rotated) == 128 + with pytest.raises(ValueError, match="repeated"): + _next_token("999", rotated) + + +def test_only_explicit_page_token_parameter_errors_authorize_reset(): + from airweave.platform.sources.gmail_pages import invalid_page_token + + assert invalid_page_token( + { + "error": { + "errors": [ + {"reason": "badRequest", "location": "pageToken", "locationType": "parameter"} + ] + } + } + ) + assert not invalid_page_token( + { + "error": { + "errors": [{"reason": "badRequest", "location": "q", "locationType": "parameter"}] + } + } + ) + assert not invalid_page_token({"error": {"message": "Invalid request"}}) + + +@pytest.mark.asyncio +async def test_bounded_native_reads_refresh_once_and_identify_only_explicit_bad_token(): + from unittest.mock import MagicMock + + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.platform.configs.config import GmailConfig + from airweave.platform.sources.gmail import GmailSource + + class RefreshingToken(StaticTokenProvider): + refreshes = 0 + + @property + def supports_refresh(self): + return True + + async def force_refresh(self): + self.refreshes += 1 + self._token = "refreshed" + return self._token + + auth = RefreshingToken("initial") + requests = [] + responses = [ + httpx.Response(401, json={"error": {"message": "expired"}}), + httpx.Response(200, json={"historyId": "current"}), + httpx.Response( + 400, + json={ + "error": { + "errors": [ + { + "reason": "badRequest", + "location": "pageToken", + "locationType": "parameter", + } + ] + } + }, + ), + ] + + async def handler(request): + requests.append(request) + return responses.pop(0) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + source = await GmailSource.create( + auth=auth, logger=MagicMock(), http_client=client, config=GmailConfig() + ) + assert await source._get_capture_json(BASE + "/profile") == {"historyId": "current"} + with pytest.raises(InvalidScanContinuation): + await source._get_capture_json(BASE + "/messages", params={"pageToken": "expired"}) + assert auth.refreshes == 1 + assert requests[0].headers["authorization"] == "Bearer initial" + assert requests[1].headers["authorization"] == "Bearer refreshed" + + +@pytest.mark.asyncio +async def test_bounded_transport_stops_before_reading_entire_oversized_body(): + from unittest.mock import MagicMock + + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.domains.storage.exceptions import FileSkippedException + from airweave.platform.configs.config import GmailConfig + from airweave.platform.sources.gmail import GmailSource + + class Chunks(httpx.AsyncByteStream): + observed = 0 + + closed = False + + def __aiter__(self): + self.iterator = self.chunks() + return self.iterator + + async def chunks(self): + for _ in range(10): + self.observed += 1 + yield b"x" * 5 + + async def aclose(self): + self.closed = True + await self.iterator.aclose() + + chunks = Chunks() + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, stream=chunks)) + ) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GmailConfig(), + ) + with pytest.raises(FileSkippedException): + await source._get_mime_json(BASE + "/messages/a", max_bytes=8) + assert chunks.observed == 2 and chunks.closed + + +@pytest.mark.asyncio +async def test_managed_proxy_decoded_headers_and_identity_request_match_bounded_reader(): + import json + from unittest.mock import MagicMock + + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.platform.configs.config import GmailConfig + from airweave.platform.http_client.composio_transport import ComposioTransport + from airweave.platform.sources.gmail import GmailSource + + async def proxy(request): + payload = json.loads(request.content) + assert any( + item["name"].lower() == "accept-encoding" and item["value"] == "identity" + for item in payload["parameters"] + ) + return httpx.Response( + 200, + json={ + "status": 200, + "data": {"historyId": "synthetic"}, + "headers": {"content-encoding": "gzip"}, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="fixture", + connected_account_id="fixture", + allowed_hosts={"gmail.googleapis.com"}, + ) + ) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GmailConfig(), + ) + assert await source._get_capture_json(BASE + "/profile") == {"historyId": "synthetic"} diff --git a/backend/airweave/platform/sources/tests/test_gmail_errors.py b/backend/airweave/platform/sources/tests/test_gmail_errors.py new file mode 100644 index 0000000000..e65709c193 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_gmail_errors.py @@ -0,0 +1,181 @@ +"""Native quota responses through both Gmail request boundaries.""" + +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from airweave.domains.sources.exceptions import SourceEntityForbiddenError +from airweave.domains.sources.exceptions.classifier import classify_error +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GmailConfig +from airweave.platform.sources.gmail import GmailSource +from airweave.platform.sources.gmail_errors import GmailThrottleError + + +def envelope(reason, *, domain="usageLimits", code=403): + return {"error": {"code": code, "errors": [{"domain": domain, "reason": reason}]}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,reason,delay", + [ + ("_get", "rateLimitExceeded", None), + ("_get_capture_json", "userRateLimitExceeded", "3"), + ("_get_capture_json", "rateLimitExceeded", "180"), + ("_get_capture_json", "rateLimitExceeded", "invalid"), + ("_get_capture_json", "rateLimitExceeded", "NaN"), + ], +) +async def test_native_quota_retry_is_bounded_and_preserves_evidence(method, reason, delay): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return httpx.Response( + 403, json=envelope(reason), headers={} if delay is None else {"Retry-After": delay} + ) + + sleep = AsyncMock() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("fixture"), + logger=Mock(), + http_client=client, + config=GmailConfig(), + ) + call = getattr(GmailSource, method).retry_with(sleep=sleep) + with pytest.raises(GmailThrottleError) as caught: + await call(source, "https://gmail.googleapis.com/gmail/v1/users/me/profile") + assert caught.value.status_code == 403 + assert caught.value.retry_after == (float(delay) if delay in {"3", "180"} else None) + assert calls == (1 if delay == "180" else 5) + assert sleep.await_count == (0 if delay == "180" else 4) + if delay == "3": + assert [call.args[0] for call in sleep.await_args_list] == [3] * 4 + elif delay != "180": + assert [call.args[0] for call in sleep.await_args_list] == [2, 2, 4, 8] + assert classify_error(caught.value).category is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + envelope("domainPolicy", domain="global"), + envelope("dailyLimitExceeded"), + envelope("unknown"), + envelope("rateLimitExceeded", domain="unknown"), + envelope("rateLimitExceeded", code=400), + {"error": {"code": 403, "errors": []}}, + {"error": {"code": 403, "errors": [{"reason": "rateLimitExceeded"}]}}, + { + "error": { + "code": 403, + "errors": [ + {"domain": "usageLimits", "reason": "rateLimitExceeded"}, + {"domain": "global", "reason": "domainPolicy"}, + ], + } + }, + "not json", + b"{broken", + ], +) +async def test_permission_unknown_and_malformed_errors_do_not_retry(body): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return ( + httpx.Response(403, content=body) + if isinstance(body, bytes) + else httpx.Response(403, json=body) + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("fixture"), + logger=Mock(), + http_client=client, + config=GmailConfig(), + ) + with pytest.raises(SourceEntityForbiddenError): + await source._get_capture_json("https://gmail.googleapis.com/gmail/v1/users/me/profile") + assert calls == 1 + + +def test_exhausted_throttle_uses_transient_validation_response(): + from airweave.domains.sources.http_translation import http_exception_for_credential_validation + + error = GmailThrottleError(None) + response = http_exception_for_credential_validation(error, source_short_name="gmail") + assert response.status_code == 502 + assert not response.headers + assert "try again later" in response.detail + + +@pytest.mark.parametrize("header", ["Wed, 01 Jan 2020 00:00:00 GMT", "-1", "inf"]) +def test_retry_timing_does_not_invent_provider_delay(header): + from airweave.platform.sources.gmail_errors import raise_gmail_throttle + + with pytest.raises(GmailThrottleError) as caught: + raise_gmail_throttle( + httpx.Response(403, json=envelope("rateLimitExceeded"), headers={"Retry-After": header}) + ) + assert caught.value.retry_after == (None if header == "inf" else 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["_get", "_get_capture_json"]) +async def test_quota_then_success_returns_provider_result(method): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return ( + httpx.Response(403, json=envelope("userRateLimitExceeded")) + if calls == 1 + else httpx.Response(200, json={"historyId": "verified"}) + ) + + sleep = AsyncMock() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("fixture"), + logger=Mock(), + http_client=client, + config=GmailConfig(), + ) + call = getattr(GmailSource, method).retry_with(sleep=sleep) + assert await call(source, "https://gmail.googleapis.com/gmail/v1/users/me/profile") == { + "historyId": "verified" + } + assert calls == 2 + sleep.assert_awaited_once_with(2) + + +def test_future_http_date_retry_after_uses_actual_delay(monkeypatch): + from datetime import datetime, timezone + + from airweave.platform.sources import gmail_errors + + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 9, 30, 20, 0, 0, tzinfo=timezone.utc) + + monkeypatch.setattr(gmail_errors, "datetime", Clock) + with pytest.raises(GmailThrottleError) as caught: + gmail_errors.raise_gmail_throttle( + httpx.Response( + 403, + json=envelope("rateLimitExceeded"), + headers={"Retry-After": "Wed, 30 Sep 2026 20:01:00 GMT"}, + ) + ) + assert caught.value.retry_after == 60 diff --git a/backend/airweave/platform/sources/tests/test_gmail_history_pages.py b/backend/airweave/platform/sources/tests/test_gmail_history_pages.py new file mode 100644 index 0000000000..64619734cb --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_gmail_history_pages.py @@ -0,0 +1,206 @@ +"""Bounded history replay uses current native message state, never event arrival state.""" + +import httpx +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.page_source import InvalidScanContinuation +from airweave.platform.sources.gmail_capture import BASE, GmailCapture, parse_history_page +from airweave.platform.sources.http_helpers import raise_for_status +from airweave.platform.sources.tests.test_gmail_capture import Provider, message + + +def history(ids, **extra): + return { + "historyId": "opaque:boundary", + "history": [{"messages": [{"id": i} for i in ids]}], + **extra, + } + + +def translated_error(status): + response = httpx.Response(status, request=httpx.Request("GET", BASE)) + try: + raise_for_status(response, source_short_name="gmail", token_provider_kind="composio") + except Exception as error: + return error + raise AssertionError("Expected source exception") + + +@pytest.mark.asyncio +async def test_one_history_entry_can_hydrate_more_than_500_messages_in_bounded_batches(): + ids = [f"m{i}" for i in range(1003)] + provider = Provider([(f"/messages/{mid}", message(mid)) for mid in ids]) + capture = GmailCapture(provider.get, None) + page = parse_history_page(history(ids)) + first = await capture.hydrate_history_page(page) + assert len(first.records) == 500 and not first.complete + second = await capture.hydrate_history_page( + page, offset=first.next_offset, expected_fingerprint=page.fingerprint + ) + final = await capture.hydrate_history_page( + page, offset=second.next_offset, expected_fingerprint=page.fingerprint + ) + assert len(second.records) == 500 and len(final.records) == 3 + assert final.next_offset == 1003 and final.complete + assert [r.identity.native_id for r in (*first.records, *second.records, *final.records)] == ids + assert not provider.steps + + +def test_all_native_arrays_ordered_dedup_and_opaque_boundary(): + raw = { + "historyId": "opaque:end", + "history": [ + { + "messages": [{"id": "a"}], + "messagesAdded": [{"message": {"id": "a"}}, {"message": {"id": "b"}}], + "messagesDeleted": [{"message": {"id": "c"}}], + "labelsAdded": [{"message": {"id": "d"}, "labelIds": ["L"]}], + "labelsRemoved": [{"message": {"id": "e"}}], + } + ], + } + page = parse_history_page(raw) + assert page.message_ids == ("a", "b", "c", "d", "e") + assert page.history_id == "opaque:end" + assert parse_history_page({**raw, "historyId": "new-current"}).fingerprint != page.fingerprint + intermediate = {**raw, "nextPageToken": "next"} + assert ( + parse_history_page(intermediate).fingerprint + == parse_history_page({**intermediate, "historyId": "new-current"}).fingerprint + ) + assert parse_history_page({**raw, "nextPageToken": "next"}).fingerprint != page.fingerprint + + +@pytest.mark.asyncio +async def test_changed_replayed_ids_reject_offset_before_any_reads(): + provider = Provider([]) + capture = GmailCapture(provider.get, None) + old = parse_history_page(history(["a", "b"])) + changed = parse_history_page(history(["b", "a"])) + with pytest.raises(InvalidScanContinuation): + await capture.hydrate_history_page(changed, offset=1, expected_fingerprint=old.fingerprint) + with pytest.raises(ValueError, match="fingerprint"): + await capture.hydrate_history_page(old, offset=1) + assert not provider.calls + + +@pytest.mark.asyncio +async def test_current_state_overrides_deleted_event_and_typed_404_tombstones(): + provider = Provider( + [ + ("/messages/alive", message("alive", labelIds=["TRASH"])), + ("/messages/gone", translated_error(404)), + ] + ) + page = parse_history_page( + { + "historyId": "opaque", + "history": [ + {"messagesDeleted": [{"message": {"id": "alive"}}, {"message": {"id": "gone"}}]} + ], + } + ) + batch = await GmailCapture(provider.get, None).hydrate_history_page(page) + assert batch.records[0].kind == "upsert" + assert batch.records[0].payload["labelIds"] == ["TRASH"] + assert batch.records[1].kind == "delete" + assert batch.records[1].removal_reason == "provider_deleted" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [401, 403, 429, 500]) +async def test_non_missing_errors_propagate_from_message_and_history(status): + error = translated_error(status) + provider = Provider([("/messages/a", error), ("/history", error)]) + capture = GmailCapture(provider.get, None) + with pytest.raises(type(error)): + await capture.hydrate_history_page(parse_history_page(history(["a"]))) + with pytest.raises(type(error)): + await capture.history_page("opaque") + + +@pytest.mark.asyncio +async def test_page_fetch_preserves_boundary_and_token_without_advancing_cursor(): + provider = Provider([("/history", history([], nextPageToken="next"))]) + page = await GmailCapture(provider.get, None).history_page( + "opaque:start", "token", max_results=1 + ) + assert provider.calls[0][1] == { + "startHistoryId": "opaque:start", + "pageToken": "token", + "maxResults": 1, + } + assert page.next_page_token == "next" and page.message_ids == () + + +@pytest.mark.parametrize( + "raw", + [ + {"historyId": 123}, + {"historyId": ""}, + {"historyId": "x", "history": [{"messages": [{"id": 7}]}]}, + {"historyId": "x", "history": [{"messagesDeleted": [{}]}]}, + ], +) +def test_malformed_provider_history_never_becomes_empty_success(raw): + with pytest.raises(ValidationError): + parse_history_page(raw) + + +@pytest.mark.asyncio +async def test_history_hydration_preserves_native_payload_and_verified_mime_bytes(): + from unittest.mock import AsyncMock + from uuid import uuid4 + + from airweave.domains.storage.file_service import FileService + + storage = AsyncMock() + files = FileService(uuid4(), storage, sync_id=uuid4()) + raw = message( + "a", + payload={ + "mimeType": "text/plain", + "body": { + "attachmentId": "body-id", + "size": 3, + }, + }, + ) + provider = Provider([("/messages/a", raw)]) + capture = GmailCapture( + provider.get, + None, + files=files, + attachment_get=AsyncMock(return_value={"data": "YWJj", "size": 3}), + ) + batch = await capture.hydrate_history_page(parse_history_page(history(["a"]))) + (record,) = batch.records + assert record.payload == raw and record.completeness == "complete" + assert record.blobs[0].source_path == "/payload/body" + storage.write_file.assert_awaited_once_with(record.blobs[0].key, b"abc") + + +@pytest.mark.asyncio +async def test_new_event_for_same_message_invalidates_partial_hydration(): + initial = { + "historyId": "first", + "history": [ + {"id": "event-one", "labelsAdded": [{"message": {"id": "a"}, "labelIds": ["INBOX"]}]} + ], + } + changed = { + "historyId": "second", + "history": [ + *initial["history"], + {"id": "event-two", "labelsRemoved": [{"message": {"id": "a"}, "labelIds": ["INBOX"]}]}, + ], + } + old, new = parse_history_page(initial), parse_history_page(changed) + assert old.message_ids == new.message_ids == ("a",) + provider = Provider([]) + with pytest.raises(InvalidScanContinuation): + await GmailCapture(provider.get, None).hydrate_history_page( + new, offset=1, expected_fingerprint=old.fingerprint + ) + assert not provider.calls diff --git a/backend/airweave/platform/sources/tests/test_gmail_identity.py b/backend/airweave/platform/sources/tests/test_gmail_identity.py new file mode 100644 index 0000000000..29eb131078 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_gmail_identity.py @@ -0,0 +1,70 @@ +"""Mailbox expectations belong to the binding, never the observed response.""" + +from unittest.mock import MagicMock + +import httpx +import pytest + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import GmailConfig +from airweave.platform.sources.gmail import GmailSource + + +@pytest.mark.asyncio +async def test_attestation_and_changed_expectation(): + requests = [] + mailbox = "person@example.test" + + def profile(request): + requests.append(request) + return httpx.Response(200, json={"emailAddress": mailbox}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(profile)) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("same-broker-account"), + logger=MagicMock(), + http_client=client, + config=GmailConfig(expected_mailbox="person@example.test"), + ) + original = source.capture_cycle_configuration.fingerprint + assert requests[0].url.params["fields"] == "emailAddress" + source.capture_config = GmailConfig(expected_mailbox="other@example.test") + with pytest.raises(ValueError, match="attested mailbox"): + _ = source.capture_cycle_configuration + with pytest.raises(ValueError, match="does not match"): + await source.validate() + source.capture_config = GmailConfig(expected_mailbox="person@example.test") + with pytest.raises(ValueError, match="attested mailbox"): + _ = source.capture_cycle_configuration + await source.validate() + assert source.capture_cycle_configuration.fingerprint == original + # A separately attested identity cannot reuse the old capture fingerprint. + mailbox = "other@example.test" + source.capture_config = GmailConfig(expected_mailbox=mailbox) + await source.validate() + assert source.capture_cycle_configuration.fingerprint != original + mailbox = "private malformed value" + with pytest.raises(ValueError, match="invalid mailbox identity") as error: + await source.validate() + assert mailbox not in str(error.value) + + +@pytest.mark.asyncio +async def test_direct_resume_without_expectation_cannot_fetch(): + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: pytest.fail("Unexpected provider call")) + ) as client: + source = await GmailSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GmailConfig(), + ) + with pytest.raises(ValueError, match="attested mailbox"): + await source.capture_page( + CompletedScope(record_type="message"), + ScanContinuation(value={}), + files=MagicMock(), + ) diff --git a/backend/airweave/platform/sources/tests/test_gmail_retry.py b/backend/airweave/platform/sources/tests/test_gmail_retry.py new file mode 100644 index 0000000000..895ad84f05 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_gmail_retry.py @@ -0,0 +1,127 @@ +"""Exercise the decorated request boundary, not just its exception predicate.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from tenacity import wait_none + +from airweave.domains.auth_provider.exceptions import ( + AuthProviderAuthError, + AuthProviderRateLimitError, + AuthProviderServerError, +) +from airweave.domains.sources.exceptions import ( + SourceAuthError, + SourceRateLimitError, + SourceServerError, +) +from airweave.domains.sources.token_providers.protocol import AuthProviderKind +from airweave.platform.sources.gmail import GmailSource + + +def request(method, effects): + upstream = AsyncMock(side_effect=effects) + source = SimpleNamespace( + _get_mime_json=upstream, + _authed_headers=AsyncMock(return_value={}), + http_client=SimpleNamespace(get=upstream), + logger=Mock(), + ) + decorated = getattr(GmailSource, method).retry_with(wait=wait_none()) + return source, upstream, decorated + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["_get", "_get_capture_json"]) +@pytest.mark.parametrize( + "error", + [ + SourceServerError(status_code=503), + AuthProviderServerError(status_code=503), + AuthProviderRateLimitError(retry_after=60), + ], +) +async def test_transient_server_error_retries_actual_request(method, error): + # For _get, return a real response; canonical JSON returns an already parsed dict. + success = ( + httpx.Response(200, json={"ok": True}, request=httpx.Request("GET", "https://example.test")) + if method == "_get" + else {"ok": True} + ) + source, upstream, call = request(method, [error, success]) + source.short_name = "gmail" + source.auth = SimpleNamespace(provider_kind=None) + assert await call(source, "https://example.test") == {"ok": True} + assert upstream.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["_get", "_get_capture_json"]) +@pytest.mark.parametrize( + "error", + [ + SourceServerError(status_code=503), + AuthProviderServerError(status_code=503), + AuthProviderRateLimitError(), + ], +) +async def test_server_error_exhausts_exactly_five_attempts(method, error): + source, upstream, call = request(method, error) + with pytest.raises(type(error)) as caught: + await call(source, "https://example.test") + assert caught.value is error + assert upstream.await_count == 5 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["_get", "_get_capture_json"]) +@pytest.mark.parametrize( + "error", + [ + SourceAuthError( + "fixture auth", + source_short_name="gmail", + status_code=401, + token_provider_kind=AuthProviderKind.STATIC, + ), + AuthProviderAuthError("fixture proxy authentication"), + AuthProviderRateLimitError(retry_after=180), + SourceRateLimitError(retry_after=180), + httpx.HTTPStatusError( + "rate limit", + request=httpx.Request("GET", "https://example.test"), + response=httpx.Response(429, headers={"Retry-After": "180"}), + ), + httpx.HTTPStatusError( + "unauthorized", + request=httpx.Request("GET", "https://example.test"), + response=httpx.Response(401), + ), + RuntimeError("budget"), + asyncio.CancelledError(), + ], +) +async def test_auth_budget_and_cancellation_do_not_retry(method, error): + source, upstream, call = request(method, error) + with pytest.raises(type(error)): + await call(source, "https://example.test") + assert upstream.await_count == 1 + + +@pytest.mark.asyncio +async def test_proxy_retry_after_within_bound_is_honored(): + source, upstream, _ = request( + "_get_capture_json", + [ + AuthProviderRateLimitError(retry_after=60), + {"ok": True}, + ], + ) + sleep = AsyncMock() + call = GmailSource._get_capture_json.retry_with(sleep=sleep) + assert await call(source, "https://example.test") == {"ok": True} + sleep.assert_awaited_once_with(60) + assert upstream.await_count == 2 diff --git a/backend/airweave/platform/sources/tests/test_outlook_calendar_capture.py b/backend/airweave/platform/sources/tests/test_outlook_calendar_capture.py new file mode 100644 index 0000000000..393ad119fa --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_outlook_calendar_capture.py @@ -0,0 +1,281 @@ +"""Synthetic Graph interpretation, not live provider or SQL recovery qualification.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle, CycleVersion +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceEntityNotFoundError, SourceServerError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import CalendarOccurrenceWindow, OutlookCalendarConfig +from airweave.platform.sources.outlook_calendar_capture import BASE, OutlookCalendarCapture +from airweave.platform.sources.outlook_graph import OutlookBoundaryError, OutlookGraphClient + +WINDOW = CalendarOccurrenceWindow(start="2026-10-01T00:00:00Z", end="2026-11-01T00:00:00Z") +ROOT = CompletedScope(record_type="calendar") +CALENDAR = {"id": "calendar", "name": "Original", "canViewPrivateItems": True} +EVENT = { + "id": "single", + "changeKey": "v1", + "type": "singleInstance", + "start": {"dateTime": "2026-10-02T10:00:00.0000000", "timeZone": "UTC"}, + "end": {"dateTime": "2026-10-02T11:00:00.0000000", "timeZone": "UTC"}, + "isAllDay": False, + "isCancelled": False, + "hasAttachments": False, + "originalStartTimeZone": "India Standard Time", + "nativeExtension": {"keep": [1, True, None]}, +} + + +def retained(identity, payload, parent=None): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=identity, + parent=parent, + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="a" * 64, + content_hash=None, + completeness="partial", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +PARENT = retained(RecordIdentity(record_type="calendar", native_id="calendar"), CALENDAR) + + +async def source_for(client): + return await OutlookCalendarCapture.create( + graph=OutlookGraphClient( + StaticTokenProvider("secret"), client, "outlook_calendar", "principal" + ), + config=OutlookCalendarConfig( + capture_originals=True, expected_principal_id="principal", occurrence_window=WINDOW + ), + ) + + +async def initial(source, parent=PARENT): + prepared = await source.prepare_cycle(None) + assert prepared.mode == "full" + assert not source.capture_cycle_configuration.scope_changes + cycle = CaptureCycle( + version=CycleVersion(cycle_id=uuid4(), revision=1), + configuration=source.capture_cycle_configuration, + mode=prepared.mode, + source_plan=prepared.source_plan, + ) + scope = source.child_scope(parent, "event") if parent else ROOT + plan = await source.prepare_scope(scope, cycle, None, parent=parent, force_full=False) + return scope, source.initial_scope_continuation(scope, cycle, plan) + + +@pytest.mark.asyncio +async def test_one_event_owner_across_full_and_expanded_pages_preserves_native_cancelled_json(): + reads = [] + + def graph(request): + path = request.url.path + if path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + reads.append(path) + assert request.headers["Prefer"] == 'IdType="ImmutableId"' + if path.endswith("/events"): + assert "startDateTime" not in request.url.params + return httpx.Response(200, json={"value": [{"id": "single"}, {"id": "master"}]}) + if path.endswith("/calendarView"): + assert request.url.params["startDateTime"] == WINDOW.start.isoformat() + return httpx.Response(200, json={"value": [{"id": "single"}, {"id": "exception"}]}) + native_id = path.rsplit("/", 1)[1] + event = {**EVENT, "id": native_id} + if native_id == "master": + event.update(type="seriesMaster", recurrence={"pattern": {"type": "weekly"}}) + if native_id == "exception": + event.update( + type="exception", + seriesMasterId="master", + isCancelled=True, + originalStart="2026-10-01T10:00:00Z", + ) + return httpx.Response(200, json=event) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + scope, cursor = await initial(source) + first = await source.capture_page(scope, cursor, files=MagicMock(), parent=PARENT) + second = await source.capture_page( + scope, first.continuation, files=MagicMock(), parent=PARENT + ) + assert not first.final and second.final + assert first.records[0].identity == second.records[0].identity + assert first.records[0].payload == EVENT + assert second.records[1].kind == "upsert" and second.records[1].payload["isCancelled"] is True + assert {r.parent for r in (*first.records, *second.records)} == {PARENT.identity} + assert all(r.completeness == "partial" for r in (*first.records, *second.records)) + assert reads.count("/v1.0/me/calendars/calendar/events/single") == 2 + + +@pytest.mark.asyncio +async def test_late_failure_retries_same_page_and_resumed_process_keeps_frozen_window(): + fail = True + requests = [] + next_link = f"{BASE}/calendars/calendar/events?$skiptoken=second" + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + requests.append(str(request.url)) + if request.url.path.endswith("/events"): + if "$skiptoken" not in request.url.params: + return httpx.Response( + 200, json={"value": [{"id": "first"}], "@odata.nextLink": next_link} + ) + return httpx.Response(200, json={"value": [{"id": "second"}, {"id": "third"}]}) + if request.url.path.endswith("/third") and fail: + return httpx.Response(503) + return httpx.Response(200, json={**EVENT, "id": request.url.path.rsplit("/", 1)[1]}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + scope, cursor = await initial(source) + first = await source.capture_page(scope, cursor, files=MagicMock(), parent=PARENT) + serialized = first.continuation.model_dump_json() + with pytest.raises(SourceServerError): + await source.capture_page(scope, first.continuation, files=MagicMock(), parent=PARENT) + assert first.continuation.model_dump_json() == serialized + fail = False + resumed = await source_for(client) + second = await resumed.capture_page( + scope, + ScanContinuation.model_validate_json(serialized), + files=MagicMock(), + parent=PARENT, + ) + assert [r.identity.native_id for r in second.records] == ["second", "third"] + assert second.continuation.value["phase"] == "calendarView" + assert requests.count(next_link) == 2 + assert second.continuation.value["context"]["window"] == WINDOW.model_dump(mode="json") + + +@pytest.mark.asyncio +async def test_exact_omissions_keep_historical_occurrences_and_ambiguous404_fails(): + missing = False + historical = { + **EVENT, + "id": "old", + "type": "occurrence", + "seriesMasterId": "master", + "start": {"dateTime": "2020-01-01T00:00:00", "timeZone": "UTC"}, + } + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + return httpx.Response(404) if missing else httpx.Response(200, json=historical) + + record = retained( + RecordIdentity(record_type="event", native_id="old", container_id="calendar"), + historical, + PARENT.identity, + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + observation = await source.refresh_known(record, files=MagicMock()) + assert observation.kind == "upsert" and observation.payload == historical + missing = True + with pytest.raises(SourceEntityNotFoundError): + await source.refresh_known(record, files=MagicMock()) + + +@pytest.mark.asyncio +async def test_inventory_permission_metadata_and_foreign_link_rejected_before_hydration(): + foreign = False + calls = [] + + def graph(request): + calls.append(request.url.path) + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + if request.url.path.endswith("/calendars"): + page = {"value": [{"id": "calendar"}]} + if foreign: + page["@odata.nextLink"] = f"{BASE}/calendars/another/events?$skiptoken=private" + return httpx.Response(200, json=page) + return httpx.Response(200, json=CALENDAR) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + scope, cursor = await initial(source, parent=None) + page = await source.capture_page(scope, cursor, files=MagicMock()) + assert page.final and page.records[0].payload == CALENDAR + assert "canViewPrivateItems" in page.records[0].descendant_visibility_fields + foreign = True + before = calls.count("/v1.0/me/calendars/calendar") + with pytest.raises(OutlookBoundaryError, match="changed its native collection"): + await source.capture_page(scope, cursor, files=MagicMock()) + assert calls.count("/v1.0/me/calendars/calendar") == before + + +@pytest.mark.asyncio +async def test_repeated_token_and_removed_full_listing_never_complete(): + removed = False + next_link = f"{BASE}/calendars/calendar/events?$skiptoken=loop" + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + if removed: + return httpx.Response( + 200, json={"value": [{"id": "x", "@removed": {"reason": "deleted"}}]} + ) + return httpx.Response(200, json={"value": [], "@odata.nextLink": next_link}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + scope, cursor = await initial(source) + page = await source.capture_page(scope, cursor, files=MagicMock(), parent=PARENT) + with pytest.raises(OutlookBoundaryError, match="repeated"): + await source.capture_page(scope, page.continuation, files=MagicMock(), parent=PARENT) + removed = True + with pytest.raises(OutlookBoundaryError, match="Invalid Outlook calendar collection"): + await source.capture_page(scope, cursor, files=MagicMock(), parent=PARENT) + + +@pytest.mark.asyncio +async def test_principal_and_scope_binding_reject_before_any_provider_read(): + calls = [] + + def graph(request): + calls.append(str(request.url)) + return httpx.Response(200, json={"id": "principal"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await source_for(client) + scope, cursor = await initial(source) + before = len(calls) + wrong = cursor.model_dump(mode="json") + wrong["value"]["context"]["calendar_id"] = "another" + with pytest.raises(OutlookBoundaryError, match="another scope"): + await source.capture_page( + scope, ScanContinuation.model_validate(wrong), files=MagicMock(), parent=PARENT + ) + source.graph.verified_principal_id = None + with pytest.raises(OutlookBoundaryError, match="not attested"): + await source.capture_page(scope, cursor, files=MagicMock(), parent=PARENT) + assert len(calls) == before diff --git a/backend/airweave/platform/sources/tests/test_outlook_identity.py b/backend/airweave/platform/sources/tests/test_outlook_identity.py new file mode 100644 index 0000000000..3b550f9758 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_outlook_identity.py @@ -0,0 +1,343 @@ +"""Outlook native identity and transport boundaries, without provider qualification.""" + +from unittest.mock import MagicMock + +import httpx +import pytest +from pydantic import SecretStr + +from airweave.domains.auth_provider.exceptions import AuthProviderAuthError +from airweave.domains.sources.exceptions import SourceAuthError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import OutlookCalendarConfig, OutlookMailConfig +from airweave.platform.http_client.composio_transport import ComposioTransport +from airweave.platform.sources.outlook_calendar import OutlookCalendarSource +from airweave.platform.sources.outlook_graph import OutlookBoundaryError +from airweave.platform.sources.outlook_mail import OutlookMailSource + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source_cls,config_cls", + [ + (OutlookMailSource, OutlookMailConfig), + (OutlookCalendarSource, OutlookCalendarConfig), + ], +) +async def test_managed_attestation_reset_blocks_list_and_attachment_reads(source_cls, config_cls): + principal = "opaque-principal" + requests = [] + + def graph(request): + requests.append(request) + assert "authorization" not in request.headers + assert "prefer" not in request.headers # No silent legacy ID migration. + return httpx.Response( + 200, json={"id": principal} if request.url.path == "/v1.0/me" else {"value": []} + ) + + auth = ManagedAuthProvider( + api_key=SecretStr("private"), + connected_account_id="account", + allowed_hosts=frozenset({"graph.microsoft.com"}), + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + with pytest.raises(OutlookBoundaryError, match="trusted expected"): + await source_cls.create( + auth=auth, logger=MagicMock(), http_client=client, config=config_cls() + ) + assert not requests + source = await source_cls.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=config_cls(expected_principal_id=principal), + ) + await source._get("https://graph.microsoft.com/v1.0/me/messages/id/attachments") + principal = "wrong-private-principal" + with pytest.raises(OutlookBoundaryError, match="does not match") as error: + await source.validate() + assert principal not in str(error.value) + count = len(requests) + with pytest.raises(OutlookBoundaryError, match="attested"): + await source._get("https://graph.microsoft.com/v1.0/me/events/id/attachments") + assert len(requests) == count + with pytest.raises(OutlookBoundaryError, match="does not match"): + await anext(source.generate_entities()) + + +@pytest.mark.asyncio +async def test_managed_proxy_and_native_401_keep_different_error_authority(): + native_status = 200 + proxy_status = 200 + + def proxy(request): + import json + + payload = json.loads(request.content) + assert payload["connected_account_id"] == "bound-account" + assert all(p["name"].lower() != "authorization" for p in payload["parameters"]) + return httpx.Response( + proxy_status, + json={ + "status": native_status, + "data": {"id": "principal", "error": "private-token"}, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as proxy_client: + transport = ComposioTransport( + api_key="private", + connected_account_id="bound-account", + allowed_hosts={"graph.microsoft.com"}, + client=proxy_client, + ) + auth = ManagedAuthProvider( + api_key=SecretStr("private"), + connected_account_id="bound-account", + allowed_hosts=frozenset({"graph.microsoft.com"}), + ) + async with httpx.AsyncClient(transport=transport) as client: + source = await OutlookMailSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=OutlookMailConfig(expected_principal_id="principal"), + ) + native_status = 401 + with pytest.raises(SourceAuthError) as error: + await source._get("https://graph.microsoft.com/v1.0/me/messages") + assert "private-token" not in str(error.value) + assert source.graph.verified_principal_id is None + proxy_status = 401 + with pytest.raises(AuthProviderAuthError): + await source.validate() + + +@pytest.mark.asyncio +async def test_unsafe_continuations_are_fatal_before_request_even_with_direct_credentials(): + calls = [] + + def graph(request): + calls.append(request) + return httpx.Response(200, json={"value": []}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await OutlookMailSource.create( + auth=StaticTokenProvider("private"), + logger=MagicMock(), + http_client=client, + config=OutlookMailConfig(), + ) + for url in ( + "https://evil.example/v1.0/me/messages?token=private", + "https://graph.microsoft.com/v1.0/users/other/messages", + "https://graph.microsoft.com/beta/me/messages", + "https://graph.microsoft.com/v1.0/me/%2e%2e/users/other", + "https://graph.microsoft.com/v1.0/me/%252e%252e/users/other", + "https://graph.microsoft.com/v1.0/me/messages#private", + ): + with pytest.raises(OutlookBoundaryError) as error: + await source._get(url) + assert "private" not in str(error.value) + assert not calls + await source.validate() # No /me permission newly required for unbound direct connections. + assert len(calls) == 1 + assert calls[0].url.path == "/v1.0/me/mailFolders" + assert calls[0].headers["authorization"] == "Bearer private" + + +@pytest.mark.asyncio +async def test_direct_refresh_rechecks_principal_before_retrying_attachment(): + class RefreshingAuth(StaticTokenProvider): + @property + def supports_refresh(self): + return True + + async def force_refresh(self): + self._token = "replacement" + return self._token + + paths = [] + + def graph(request): + paths.append(request.url.path) + if request.url.path == "/v1.0/me": + identity = ( + "original" if request.headers["authorization"] == "Bearer initial" else "other" + ) + return httpx.Response(200, json={"id": identity}) + return httpx.Response(401, json={"error": {"message": "private-details"}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await OutlookMailSource.create( + auth=RefreshingAuth("initial"), + logger=MagicMock(), + http_client=client, + config=OutlookMailConfig(expected_principal_id="original"), + ) + with pytest.raises(OutlookBoundaryError, match="does not match"): + await source._get("https://graph.microsoft.com/v1.0/me/messages/id/attachments") + assert paths == ["/v1.0/me", "/v1.0/me/messages/id/attachments", "/v1.0/me"] + assert source.graph.verified_principal_id is None + + +@pytest.mark.asyncio +async def test_broker_outlook_mapping_reuses_bound_graph_transport(monkeypatch): + from unittest.mock import AsyncMock + + from airweave.domains.auth_provider.exceptions import AuthProviderConfigError + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, config={"account_id": "selected", "user_id": "owner"} + ) + account = { + "id": "selected", + "user_id": "owner", + "toolkit": {"slug": "outlook"}, + "status": "ACTIVE", + } + monkeypatch.setattr(provider, "_get_with_auth", AsyncMock(return_value=account)) + for name in ("outlook_mail", "outlook_calendar"): + result = await provider.get_auth_result(name, []) + assert result.credentials is None + assert result.managed_auth.allowed_hosts == frozenset({"graph.microsoft.com"}) + assert result.managed_auth.connected_account_id == "selected" + account["toolkit"]["slug"] = "other" + with pytest.raises(AuthProviderConfigError, match="toolkit"): + await provider.get_auth_result("outlook_mail", []) + + +@pytest.mark.asyncio +async def test_malformed_principal_and_error_envelope_do_not_become_empty_success(): + data = {"id": "principal"} + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=data)) + ) as client: + source = await OutlookCalendarSource.create( + auth=StaticTokenProvider("secret"), + logger=MagicMock(), + http_client=client, + config=OutlookCalendarConfig(expected_principal_id="principal"), + ) + for invalid_body in (None, [], "private-body"): + data = invalid_body + with pytest.raises(OutlookBoundaryError, match="invalid JSON"): + await source._get("https://graph.microsoft.com/v1.0/me/calendars") + data = {"error": {"message": "private-message"}} + with pytest.raises(OutlookBoundaryError, match="error envelope") as error: + await source._get("https://graph.microsoft.com/v1.0/me/calendars") + assert "private-message" not in str(error.value) + with pytest.raises(OutlookBoundaryError, match="invalid principal"): + await source.validate() + assert source.graph.verified_principal_id is None + + +@pytest.mark.asyncio +async def test_attachment_pagination_cannot_swallow_unsafe_continuation(): + calls = [] + + def graph(request): + calls.append(request) + return httpx.Response( + 200, + json={ + "value": [], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/users/other/messages?token=private", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await OutlookMailSource.create( + auth=StaticTokenProvider("secret"), + logger=MagicMock(), + http_client=client, + config=OutlookMailConfig(), + ) + with pytest.raises(OutlookBoundaryError, match="outside the bound mailbox"): + await anext(source._process_attachments("message-id", [], None)) + assert len(calls) == 1 + assert calls[0].url.path == "/v1.0/me/messages/message-id/attachments" + assert "private" not in str(source.logger.method_calls) + + +@pytest.mark.parametrize("capture_originals", [False, True]) +async def test_source_creation_selects_capture_adapter_without_replacing_auth(capture_originals): + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + + def graph(request): + assert request.url.path == "/v1.0/me" + return httpx.Response(200, json={"id": "principal"}) + + auth = StaticTokenProvider("synthetic") + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await OutlookMailSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=OutlookMailConfig( + capture_originals=capture_originals, + expected_principal_id="principal", + included_folders=[], + excluded_folders=[], + ), + ) + assert source.auth is auth + if capture_originals: + assert isinstance(source.capture_page_source, CanonicalPageSource) + assert source.capture_page_source.graph is source.graph + else: + assert source.capture_page_source is None + + +async def test_delta_expiry_classification_requires_structured_code_and_delta_endpoint(): + from airweave.domains.sources.exceptions import SourceEntityNotFoundError + from airweave.platform.sources.outlook_graph import OutlookDeltaExpiredError, OutlookGraphClient + + code = "syncStateNotFound" + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + return httpx.Response(404, json={"error": {"code": code, "message": "private-value"}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = OutlookGraphClient( + StaticTokenProvider("secret"), client, "outlook_mail", "principal" + ) + await source.verify_principal() + delta = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta" + with pytest.raises(OutlookDeltaExpiredError) as error: + await source.get(delta) + assert "private-value" not in str(error.value) + with pytest.raises(SourceEntityNotFoundError): + await source.get("https://graph.microsoft.com/v1.0/me/messages/message") + code = "ErrorItemNotFound" + with pytest.raises(SourceEntityNotFoundError): + await source.get(delta) + + +def test_owned_calendar_config_requires_principal_and_preserves_explicit_window(): + """Owned activation needs identity; recurrence bounds are explicit and reproducible.""" + from datetime import datetime, timezone + + from pydantic import ValidationError + + from airweave.platform.configs.config import CalendarOccurrenceWindow + + with pytest.raises(ValidationError, match="expected_principal_id"): + OutlookCalendarConfig(capture_originals=True) + start = datetime(2026, 10, 1, tzinfo=timezone.utc) + end = datetime(2026, 11, 1, tzinfo=timezone.utc) + explicit = CalendarOccurrenceWindow(start=start, end=end) + config = OutlookCalendarConfig( + capture_originals=True, + expected_principal_id="principal", + occurrence_window=explicit, + ) + assert config.resolved_window() == explicit + assert not OutlookCalendarConfig().capture_originals + with pytest.raises(ValidationError): + OutlookCalendarConfig(occurrence_window={"start": end, "end": start}) diff --git a/backend/airweave/platform/sources/tests/test_outlook_mail_capture.py b/backend/airweave/platform/sources/tests/test_outlook_mail_capture.py new file mode 100644 index 0000000000..3279531c4e --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_outlook_mail_capture.py @@ -0,0 +1,459 @@ +"""Synthetic native interpretation; SQL page durability is tested by the shared engine suite.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import httpx +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import BlobReference, CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceEntityNotFoundError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import OutlookMailConfig +from airweave.platform.sources.outlook_graph import OutlookBoundaryError, OutlookGraphClient +from airweave.platform.sources.outlook_mail_capture import OutlookMailCapture +from airweave.platform.sources.outlook_mail_models import OutlookMailContinuation + +MIME = b"From: from@example.com\r\nTo: to@example.com\r\nSubject: Original\r\n\r\nFull body\r\n" +MESSAGE = { + "id": "immutable-message", + "changeKey": "version-one", + "parentFolderId": "folder-b", + "subject": "Original", + "from": {"emailAddress": {"address": "from@example.com"}}, + "sender": {"emailAddress": {"address": "delegate@example.com"}}, + "body": {"contentType": "text", "content": "Full body"}, + "bodyPreview": "Full", + "hasAttachments": False, + "nativeExtension": {"keep": [1, True]}, +} +ROOT = CompletedScope(record_type="message") + + +def files(maximum=10000): + result = MagicMock() + result.MAX_FILE_SIZE_BYTES = maximum + result.store_canonical_blob = AsyncMock( + return_value=BlobReference( + key="original", sha256="a" * 64, size_bytes=len(MIME), media_type="message/rfc822" + ) + ) + return result + + +async def capture(client, *, config=None): + return await OutlookMailCapture.create( + graph=OutlookGraphClient( + StaticTokenProvider("secret"), client, "outlook_mail", "principal" + ), + config=config + or OutlookMailConfig( + expected_principal_id="principal", included_folders=[], excluded_folders=[] + ), + ) + + +def initial(source): + return ScanContinuation( + value=OutlookMailContinuation(fingerprint=source.fingerprint).model_dump(mode="json") + ) + + +async def first_message_page(source, retained): + progress = initial(source) + while True: + page = await source.capture_page(ROOT, progress, files=retained) + if page.records or page.final: + return page + progress = page.continuation + + +def topology(request): + if request.url.path == "/v1.0/me/mailFolders": + assert request.url.params["includeHiddenFolders"] == "true" + return httpx.Response(200, json={"value": [{"id": "folder-b"}]}) + if request.url.path.endswith("/childFolders"): + assert request.url.params["includeHiddenFolders"] == "true" + return httpx.Response(200, json={"value": []}) + return None + + +@pytest.mark.asyncio +async def test_full_page_retains_exact_json_and_mime_and_resumes_pending_id(): + requested = [] + fail_second = True + + def graph(request): + requested.append(request.url.path) + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + assert request.headers["Prefer"] == 'IdType="ImmutableId"' + folder_page = topology(request) + if folder_page is not None: + return folder_page + if request.url.path.endswith("/messages/delta"): + return httpx.Response( + 200, + json={ + "value": [{"id": "immutable-message"}, {"id": "second"}], + "@odata.deltaLink": str(request.url.copy_with(query=b"$deltatoken=end")), + }, + ) + if request.url.path.endswith("/$value"): + if fail_second and "/second/" in request.url.path: + raise httpx.ReadError("private-url-failure") + return httpx.Response(200, content=MIME, headers={"content-type": "message/rfc822"}) + mid = request.url.path.rsplit("/", 1)[-1] + return httpx.Response(200, json={**MESSAGE, "id": mid}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + retained = files() + first = await first_message_page(source, retained) + assert first.records[0].payload == MESSAGE + assert first.records[0].parent is None + assert first.records[0].completeness == "partial" + assert first.records[0].blobs[0].source_path is None + assert not first.final + retained.store_canonical_blob.assert_awaited_once_with(MIME, media_type="message/rfc822") + saved = first.continuation.model_copy(deep=True) + with pytest.raises(httpx.RequestError) as error: + await source.capture_page(ROOT, saved, files=retained) + assert "private-url-failure" not in str(error.value) + assert saved == first.continuation + fail_second = False + recreated = await capture(client) + second = await recreated.capture_page(ROOT, saved, files=retained) + assert second.final and second.records[0].identity.native_id == "second" + assert requested.count("/v1.0/me/mailFolders/folder-b/messages/delta") == 1 + assert second.provider_checkpoint is not None + + +@pytest.mark.asyncio +async def test_current_folder_decides_move_membership_and_ambiguous_404_stops(): + message = dict(MESSAGE) + unavailable = False + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + if "/mailFolders/" in request.url.path: + return httpx.Response(200, json={"id": request.url.path.rsplit("/", 1)[-1]}) + if unavailable: + return httpx.Response(404, json={"error": {"code": "ErrorItemNotFound"}}) + if request.url.path.endswith("/$value"): + return httpx.Response(200, content=MIME, headers={"content-type": "text/plain"}) + return httpx.Response(200, json=message) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture( + client, + config=OutlookMailConfig( + expected_principal_id="principal", + included_folders=["folder-a", "folder-b"], + excluded_folders=["excluded"], + ), + ) + retained = files() + # Duplicate discoveries before/after a folder move keep the same root identity. + first = await source.message(message["id"], files=retained) + message["parentFolderId"] = "folder-a" + second = await source.message(message["id"], files=retained) + assert first.identity == second.identity and first.kind == second.kind == "upsert" + assert first.parent is second.parent is None + message["parentFolderId"] = "excluded" + outside = await source.message(message["id"], files=retained) + assert outside.removal_reason == "scope_removed" + assert retained.store_canonical_blob.await_count == 2 + unavailable = True + previous = SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=first.identity, + revision=1, + payload=first.payload, + payload_schema_version=1, + capture_hash="a" * 64, + content_hash=None, + completeness=first.completeness, + observed_at=first.observed_at, + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=first.blobs, + indexed_revision=None, + indexed_pipeline_version=None, + ) + with pytest.raises(SourceEntityNotFoundError): + await source.refresh_known(previous, files=retained) + + +@pytest.mark.asyncio +async def test_cursor_binding_and_wrong_collection_fail_before_message_reads(): + requested = [] + next_link = "https://graph.microsoft.com/v1.0/me/mailFolders/folder-b/messages/delta?$skip=next" + + def graph(request): + requested.append(request.url.path) + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + folder_page = topology(request) + if folder_page is not None: + return folder_page + return httpx.Response(200, json={"value": [], "@odata.nextLink": next_link}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + wrong = initial(source).model_copy(update={"value": {"fingerprint": "a" * 64}}) + with pytest.raises(OutlookBoundaryError, match="another capture"): + await source.capture_page(ROOT, wrong, files=files()) + assert requested == ["/v1.0/me"] + next_link = "https://graph.microsoft.com/v1.0/me/events" + with pytest.raises(OutlookBoundaryError, match="changed the native collection"): + await first_message_page(source, files()) + next_link = ( + "https://graph.microsoft.com/v1.0/me/mailFolders/folder-b/messages/delta?token=" + + "x" * 66000 + ) + with pytest.raises(OutlookBoundaryError, match="64 KiB"): + await first_message_page(source, files()) + + +@pytest.mark.asyncio +async def test_mime_size_omission_is_explicit_and_change_key_race_cannot_commit(): + changed = False + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + if request.url.path.endswith("/$value"): + return httpx.Response( + 200, content=MIME, headers={"content-type": "Message/RFC822; charset=utf-8"} + ) + value = dict(MESSAGE) + if changed and "$select" in request.url.params: + value["changeKey"] = "new-version" + return httpx.Response(200, json=value) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + bounded = files(maximum=8) + original = await source.message(MESSAGE["id"], files=bounded) + assert original.completeness == "metadata_only" and original.blobs == () + assert original.payload == MESSAGE + bounded.store_canonical_blob.assert_not_awaited() + changed = True + with pytest.raises(OutlookBoundaryError, match="changed during"): + await source.message(MESSAGE["id"], files=files()) + + +@pytest.mark.asyncio +async def test_missing_received_date_cannot_broaden_date_selection(): + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + assert not request.url.path.endswith("/$value") + return httpx.Response(200, json=MESSAGE) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture( + client, + config=OutlookMailConfig( + expected_principal_id="principal", + after_date="2026/01/01", + included_folders=[], + excluded_folders=[], + ), + ) + with pytest.raises(OutlookBoundaryError, match="requires a received timestamp"): + await source.message(MESSAGE["id"], files=files()) + + +@pytest.mark.parametrize("order", [("old", "new"), ("new", "old")]) +async def test_delta_move_orders_hydrate_mailbox_and_changes_preserve_scope_removal(order): + """Removed events are invalidations, never deletion authority.""" + folder = "new" + unavailable = False + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + assert request.headers["Prefer"] == 'IdType="ImmutableId"' + if request.url.path.endswith("/messages/delta"): + event = {"id": MESSAGE["id"]} + if "/old/" in request.url.path: + event["@removed"] = {"reason": "deleted"} + return httpx.Response( + 200, + json={ + "value": [event], + "@odata.deltaLink": str(request.url.copy_with(query=b"$deltatoken=next")), + }, + ) + if unavailable: + return httpx.Response(404, json={"error": {"code": "ErrorItemNotFound"}}) + if request.url.path.endswith("/$value"): + return httpx.Response(200, content=MIME, headers={"content-type": "message/rfc822"}) + return httpx.Response(200, json={**MESSAGE, "parentFolderId": folder}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + source.excluded_ids = frozenset({"excluded"}) + state = OutlookMailContinuation( + fingerprint=source.fingerprint, + mode="changes", + phase="messages", + folders_to_visit=(), + remaining_folders=order, + ) + cursor = source._continuation(state) + first = await source.capture_page(ROOT, cursor, files=files()) + second = await source.capture_page(ROOT, first.continuation, files=files()) + assert first.records[0].identity == second.records[0].identity + assert first.records[0].kind == second.records[0].kind == "upsert" + assert first.provider_checkpoint is None and second.provider_checkpoint is not None + assert second.final + folder = "excluded" + withdrawn = await source.capture_page(ROOT, cursor, files=files()) + assert withdrawn.records[0].removal_reason == "scope_removed" + unavailable = True + with pytest.raises(SourceEntityNotFoundError): + await source.capture_page(ROOT, cursor, files=files()) + assert cursor == source._continuation(state) + + +async def test_recursive_hidden_topology_new_folder_and_disappeared_folder_reset(): + from airweave.domains.entities.canonical.page_source import InvalidCaptureCheckpoint + + calls = [] + unknown_folder = False + + def graph(request): + path = request.url.path + calls.append(path) + if path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + assert request.url.params["includeHiddenFolders"] == "true" + if path.endswith("/mailFolders"): + value = [{"id": "parent"}] + elif path.endswith("/parent/childFolders"): + value = [{"id": "hidden-child", "isHidden": True}] + else: + value = [] + if unknown_folder and value: + value[0]["@odata.type"] = "#microsoft.graph.unknownFolder" + return httpx.Response(200, json={"value": value}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + cursor = initial(source) + for _ in range(3): + page = await source.capture_page(ROOT, cursor, files=files()) + cursor = page.continuation + state = OutlookMailContinuation.model_validate(cursor.value) + assert state.phase == "messages" + assert set(state.remaining_folders) == {"parent", "hidden-child"} + assert calls[-1].endswith("/hidden-child/childFolders") + missing = OutlookMailContinuation( + fingerprint=source.fingerprint, + mode="changes", + folder_links={"removed": source._delta_url("removed") + "?$deltatoken=old"}, + ) + cursor = source._continuation(missing) + for _ in range(2): + cursor = (await source.capture_page(ROOT, cursor, files=files())).continuation + with pytest.raises(InvalidCaptureCheckpoint, match="disappeared"): + await source.capture_page(ROOT, cursor, files=files()) + unknown_folder = True + with pytest.raises(OutlookBoundaryError, match="not qualified"): + await source.capture_page(ROOT, initial(source), files=files()) + + +async def test_expired_delta_restarts_without_treating_other_errors_as_empty(): + from airweave.domains.entities.canonical.page_source import InvalidCaptureCheckpoint + from airweave.domains.sources.exceptions import SourceError + + code = "syncStateNotFound" + + def graph(request): + if request.url.path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + return httpx.Response(400, json={"error": {"code": code, "message": "private-error"}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture(client) + cursor = source._continuation( + OutlookMailContinuation( + fingerprint=source.fingerprint, + mode="changes", + phase="messages", + folders_to_visit=(), + remaining_folders=("folder",), + folder_links={"folder": source._delta_url("folder") + "?$deltatoken=old"}, + ) + ) + with pytest.raises(InvalidCaptureCheckpoint): + await source.capture_page(ROOT, cursor, files=files()) + code = "ErrorInvalidRequest" + with pytest.raises(SourceError) as error: + await source.capture_page(ROOT, cursor, files=files()) + assert "private-error" not in str(error.value) + + +@pytest.mark.parametrize("selected_view", [False, True]) +async def test_virtual_folder_traverses_physical_child_but_never_owns_message_delta(selected_view): + requests = [] + + def graph(request): + path = request.url.path + requests.append(path) + assert request.headers["Accept"] == "application/json;odata.metadata=minimal" + if path == "/v1.0/me": + return httpx.Response(200, json={"id": "principal"}) + if path.endswith("/mailFolders/view"): + return httpx.Response(200, json={"id": "view"}) + if path.endswith("/messages/delta"): + assert "/view/" not in path + return httpx.Response( + 200, + json={ + "value": [], + "@odata.deltaLink": str(request.url.copy_with(query=b"$deltatoken=end")), + }, + ) + assert request.url.params["includeHiddenFolders"] == "true" + if path.endswith("/mailFolders"): + value = [ + {"id": "physical"}, + {"id": "view", "@odata.type": "#microsoft.graph.mailSearchFolder"}, + ] + elif path.endswith("/view/childFolders"): + value = [{"id": "nested-physical", "isHidden": True}] + else: + value = [] + return httpx.Response(200, json={"value": value}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(graph)) as client: + source = await capture( + client, + config=OutlookMailConfig( + expected_principal_id="principal", + included_folders=["view"] if selected_view else [], + excluded_folders=[], + ), + ) + if selected_view: + with pytest.raises(OutlookBoundaryError, match="search-folder selections"): + await source.capture_page(ROOT, initial(source), files=files()) + return + terminal = await first_message_page(source, files()) + assert terminal.final and terminal.records == () + assert set(terminal.provider_checkpoint.value["folder_links"]) == { + "physical", + "nested-physical", + } + assert "/v1.0/me/mailFolders/view/childFolders" in requests + assert "/v1.0/me/mailFolders/view/messages/delta" not in requests diff --git a/backend/airweave/platform/sources/tests/test_slack_history_limit.py b/backend/airweave/platform/sources/tests/test_slack_history_limit.py new file mode 100644 index 0000000000..71f0e1b931 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_slack_history_limit.py @@ -0,0 +1,29 @@ +"""Unusable coverage flags must never become a complete channel inventory.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackSource + + +@pytest.mark.asyncio +@pytest.mark.parametrize("flag", ["false", None]) +async def test_malformed_history_limit_cannot_authorize_completion(flag): + source = SlackSource( + auth=StaticTokenProvider("synthetic"), logger=MagicMock(), http_client=MagicMock() + ) + source.slack_config = SlackConfig(expected_team_id="T1", expected_user_id="U1") + source._get = AsyncMock(return_value={"ok": True, "team_id": "T1", "user_id": "U1"}) + await source.validate() + source._get = AsyncMock(return_value={"messages": [], "is_limited": flag}) + with pytest.raises(ValueError, match="invalid page"): + await source.capture_page( + CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(value={}), + files=MagicMock(), + ) diff --git a/backend/airweave/platform/sources/tests/test_slack_identity.py b/backend/airweave/platform/sources/tests/test_slack_identity.py new file mode 100644 index 0000000000..de67a61a10 --- /dev/null +++ b/backend/airweave/platform/sources/tests/test_slack_identity.py @@ -0,0 +1,66 @@ +"""Private-channel visibility is bound to both native workspace and user.""" + +from unittest.mock import MagicMock + +import httpx +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackSource + + +@pytest.mark.asyncio +async def test_native_user_change_clears_attestation_and_changes_fingerprint(): + profile = {"ok": True, "team_id": "T1", "user_id": "U1"} + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, json=profile)) + ) as client: + source = await SlackSource.create( + auth=StaticTokenProvider("same-broker"), + logger=MagicMock(), + http_client=client, + config=SlackConfig(expected_team_id="T1", expected_user_id="U1"), + ) + original = source.capture_cycle_configuration.fingerprint + profile["user_id"] = "U2" + with pytest.raises(ValueError, match="does not match"): + await source.validate() + with pytest.raises(ValueError, match="attested workspace"): + await source.capture_page( + CompletedScope(record_type="channel"), ScanContinuation(), files=MagicMock() + ) + source.slack_config = SlackConfig(expected_team_id="T1", expected_user_id="U2") + await source.validate() + assert source.capture_cycle_configuration.fingerprint != original + profile["ok"] = "true" + with pytest.raises(ValueError, match="invalid native identity"): + await source.validate() + with pytest.raises(ValueError, match="attested workspace"): + _ = source.capture_cycle_configuration + + +@pytest.mark.asyncio +async def test_missing_pair_cannot_start_capture(): + with pytest.raises(ValidationError, match="both team and user"): + SlackConfig(expected_team_id="T1") + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: pytest.fail("Unbound capture fetched provider data") + ) + ) as client: + source = await SlackSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=SlackConfig(), + ) + with pytest.raises(ValueError, match="attested workspace"): + _ = source.capture_cycle_configuration + with pytest.raises(ValueError, match="attested workspace"): + await source.capture_page( + CompletedScope(record_type="channel"), ScanContinuation(), files=MagicMock() + ) diff --git a/backend/airweave/platform/sources/todoist.py b/backend/airweave/platform/sources/todoist.py index 421f974ae8..3f3af64c7b 100644 --- a/backend/airweave/platform/sources/todoist.py +++ b/backend/airweave/platform/sources/todoist.py @@ -24,12 +24,12 @@ TodoistTaskEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/trello.py b/backend/airweave/platform/sources/trello.py index bce66022c8..74568d7d0a 100644 --- a/backend/airweave/platform/sources/trello.py +++ b/backend/airweave/platform/sources/trello.py @@ -32,12 +32,12 @@ TrelloMemberEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/wispr.py b/backend/airweave/platform/sources/wispr.py new file mode 100644 index 0000000000..c703856b21 --- /dev/null +++ b/backend/airweave/platform/sources/wispr.py @@ -0,0 +1,539 @@ +"""Capture Wispr meeting and scratchpad representations through account-bound sessions.""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from typing import Literal +from urllib.parse import quote + +import httpx +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, StrictBool + +from airweave.core.logging import ContextualLogger +from airweave.domains.entities.canonical.cycle_models import CycleConfiguration +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import CapturePage +from airweave.domains.entities.canonical.requests import ( + CaptureRecord, + CompletedScope, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceError, SourceServerError +from airweave.domains.sources.token_providers.protocol import ManagedToolAuthProvider +from airweave.domains.storage import FileSkippedException +from airweave.domains.storage.file_service import FileService +from airweave.platform.decorators import source +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.bounded_response import bounded_response_bytes +from airweave.platform.http_client.composio_transport import ComposioTransport +from airweave.platform.sources._base import BaseSource +from airweave.schemas.source_connection import AuthenticationMethod + +# Decoded response and retained archive limits; overflow fails without advancing this body. +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +MAX_ERROR_BYTES = 64 * 1024 +MAX_BODY_BYTES = 32 * 1024 * 1024 + + +class SessionResponse(BaseModel): + """Only the session identifier is needed; other routing metadata is ignored.""" + + session_id: str + + +class ToolResponse(BaseModel): + """Preserve the entire native tool result, without persisting Composio log IDs.""" + + data: dict[str, JsonValue] + error: JsonValue = None + + +class MeetingPage(BaseModel): + """Wispr's real search response pagination fields.""" + + model_config = ConfigDict(extra="ignore") + meetings: list[dict[str, JsonValue]] = Field(max_length=200) + has_more: StrictBool + next_cursor: str | None = None + truncated: StrictBool = False + + +class ScratchpadPage(BaseModel): + """Scratchpad search uses notes and modification-time filtering.""" + + model_config = ConfigDict(extra="ignore") + notes: list[dict[str, JsonValue]] = Field(max_length=200) + has_more: StrictBool + next_cursor: str | None = None + truncated: StrictBool = False + + +class _Listing(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(min_length=1) + + +class _Version(BaseModel): + model_config = ConfigDict(extra="ignore") + modified_at: AwareDatetime | None = None + + +class _Window(BaseModel): + model_config = ConfigDict(extra="forbid") + since: AwareDatetime | None = None + until: AwareDatetime | None = None + depth: int = Field(default=0, ge=0, le=32) + + +class _MeetingStart(BaseModel): + model_config = ConfigDict(extra="ignore") + start: AwareDatetime | None = None + + +class _Progress(BaseModel): + model_config = ConfigDict(extra="forbid") + cursor: str | None = Field(default=None, min_length=1, max_length=8192) + seen: list[str] = Field(default_factory=list, max_length=1000) + count: int = Field(default=0, ge=0, le=1000) + window: _Window = Field(default_factory=_Window) + pending: list[_Window] = Field(default_factory=list, max_length=33) + dates_complete: bool = True + earliest: AwareDatetime | None = None + latest: AwareDatetime | None = None + + +@source( + name="Wispr", + short_name="wispr", + auth_methods=[AuthenticationMethod.AUTH_PROVIDER], + labels=["Meetings"], + supports_continuous=False, +) +class WisprSource(BaseSource): + """Capture available notes, summaries and transcript ranges; never infer deletions. + + The provider omits its raw editor JSON and has no deletion feed or snapshot cursor. + Captures remain explicitly partial, even after every exposed text range is fetched. + """ + + canonical_record_types = ("meeting_listing", "meeting", "scratchpad_listing", "scratchpad_note") + canonical_container_parents = { + "meeting": "meeting_listing", + "scratchpad_note": "scratchpad_listing", + } + + @property + def capture_cycle_configuration(self) -> CycleConfiguration: + """Bind durable discovery policy to this account and representation version.""" + return self._cycle_configuration + + @classmethod + async def create( + cls, + *, + auth: ManagedToolAuthProvider, + logger: ContextualLogger, + http_client: AirweaveHttpClient, + config: BaseModel | None = None, + ) -> WisprSource: + """Require an explicit tool session capability, never a raw token.""" + if not isinstance(auth, ManagedToolAuthProvider): + raise TypeError("Wispr requires managed tool authentication") + instance = cls(auth=auth, logger=logger, http_client=http_client) + instance._tool_auth = auth + instance._session_id: str | None = None + fingerprint = hashlib.sha256( + json.dumps( + { + "version": 3, + "account": auth.connected_account_id, + "kinds": cls.canonical_record_types, + "policy": "discovery_only", + "listing_limit": 200, + }, + sort_keys=True, + ).encode() + ).hexdigest() + instance._cycle_configuration = CycleConfiguration.from_source( + fingerprint=fingerprint, + record_types=cls.canonical_record_types, + container_parents=cls.canonical_container_parents, + completion_policies={ + "meeting_listing": "discovery_only", + "scratchpad_listing": "discovery_only", + }, + ) + return instance + + async def _post(self, path: str, body: dict) -> dict: + async with self.http_client.stream( + "POST", + "https://backend.composio.dev/api/v3.1/tool_router/" + path, + headers={ + "x-api-key": self._tool_auth.api_key.get_secret_value(), + "Accept-Encoding": "identity", + }, + json=body, + ) as response: + if response.headers.get("content-encoding", "identity").lower() != "identity": + if not response.is_success: + ComposioTransport._check_proxy_response( + httpx.Response( + response.status_code, + headers=response.headers, + request=response.request, + content=b"", + ), + response.request, + ) + raise SourceError( + "Wispr did not honor bounded identity encoding", source_short_name="wispr" + ) + maximum = MAX_RESPONSE_BYTES if response.is_success else MAX_ERROR_BYTES + try: + raw = await bounded_response_bytes(response, maximum, label="Wispr response") + except FileSkippedException as error: + # Preserve status-based authentication/throttle failures without retaining an + # oversized error body. Successful overflow is never a storage skip. + if not response.is_success: + ComposioTransport._check_proxy_response( + httpx.Response( + response.status_code, + headers=response.headers, + request=response.request, + content=b"", + ), + response.request, + ) + raise SourceError( + "Wispr response exceeds byte limit; capture is incomplete", + source_short_name="wispr", + ) from error + buffered = httpx.Response( + response.status_code, + headers=response.headers, + request=response.request, + content=raw, + ) + ComposioTransport._check_proxy_response(buffered, response.request) + value = buffered.json() + if not isinstance(value, dict): + raise SourceError("Wispr response must be an object", source_short_name="wispr") + return value + + async def _execute(self, slug: str, arguments: dict) -> dict[str, JsonValue]: + if self._session_id is None: + session = SessionResponse.model_validate( + await self._post( + "session", + { + "user_id": self._tool_auth.user_id, + "connected_accounts": { + "wispr_flow_mcp": [self._tool_auth.connected_account_id] + }, + "toolkits": {"enable": ["wispr_flow_mcp"]}, + "manage_connections": {"enable": False}, + }, + ) + ) + self._session_id = session.session_id + result = ToolResponse.model_validate( + await self._post( + "session/" + quote(self._session_id, safe="") + "/execute", + { + "tool_slug": slug, + "arguments": arguments, + }, + ) + ) + if isinstance(result.error, str) and any( + phrase in result.error.lower() for phrase in ("rate limit", "too many requests") + ): + # HTTP 200 tool text is a diagnostic signal, not a structured 429 or + # a provider Retry-After contract. Keep the failure terminal here. + raise SourceServerError( + "Managed Wispr tool reported a rate-limit signal; " + "the limiting service and retry delay are unknown. Capture is incomplete.", + source_short_name="wispr", + ) + if result.error is not None: + raise ValueError("Wispr tool execution failed; capture is incomplete") + return result.data + + async def validate(self) -> None: + """Verify the bound session can discover both supported Wispr inventories.""" + MeetingPage.model_validate( + await self._execute("WISPR_FLOW_MCP_SEARCH_MEETINGS", {"limit": 1}) + ) + ScratchpadPage.model_validate( + await self._execute("WISPR_FLOW_MCP_SEARCH_SCRATCHPAD_NOTES", {"limit": 1}) + ) + + @staticmethod + def _next_offset(text: JsonValue, field: str) -> int | None: + if not isinstance(text, str): + raise ValueError("Wispr requested text is not a string") + matches = list( + re.finditer( + rf"\(\.\.\.truncated, \d+ chars remaining; continue with " + rf"view_{field}\.start_char=(\d+)\.\.\.\)\s*$", + text, + re.MULTILINE, + ) + ) + if len(matches) > 1: + raise ValueError("Wispr returned ambiguous continuation markers") + return int(matches[0].group(1)) if matches else None + + @staticmethod + def _require_range_version(version: JsonValue) -> None: + """Missing versions cannot establish consistency across separate native reads.""" + if not isinstance(version, str) or not version.strip(): + raise ValueError("Wispr paginated body has no usable version; capture is incomplete") + + @staticmethod + def _archive_size(value: JsonValue, previous: int = 0) -> int: + """Count complete native JSON and range descriptors once per acquired part.""" + size = previous + len( + json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + if size > MAX_BODY_BYTES: + raise SourceError( + "Wispr body exceeds byte limit; capture is incomplete", source_short_name="wispr" + ) + return size + + async def _body( + self, kind: Literal["meeting", "scratchpad_note"], identity: str + ) -> dict[str, JsonValue]: + """Retain complete returned ranges for the two concrete Wispr body tools.""" + if kind == "meeting": + slug, id_field, offsets = ( + "WISPR_FLOW_MCP_GET_MEETING", + "meeting_id", + {"content": 0, "transcript": 0}, + ) + else: + slug, id_field, offsets = ( + "WISPR_FLOW_MCP_GET_SCRATCHPAD_NOTE", + "note_id", + {"content": 0}, + ) + responses: list[JsonValue] = [] + archive_bytes = len(b'{"responses":[]}') + for _ in range(100): + arguments = { + id_field: identity, + **{ + "view_" + field: {"char_limit": 40000, "start_char": offset} + for field, offset in offsets.items() + }, + } + response = await self._execute(slug, arguments) + if response.get("id") != identity: + raise ValueError("Wispr returned a different body identity") + if responses and response.get("modified_at") != responses[0]["response"].get( + "modified_at" + ): + raise ValueError("Wispr body changed during paginated capture") + retained = {"requested_ranges": arguments, "response": response} + archive_bytes = self._archive_size(retained, archive_bytes + bool(responses)) + responses.append(retained) + following = {} + for field, offset in offsets.items(): + if ( + field == "transcript" + and offset == 0 + and response.get("has_transcript") is False + and "transcript" in response + and response["transcript"] is None + ): + # Explicit absence is not a failed range; retain native null unchanged. + continue + next_offset = self._next_offset(response.get(field), field) + if next_offset is not None: + if next_offset <= offset: + raise ValueError("Wispr continuation made no progress") + following[field] = next_offset + if not following: + return {"responses": responses} + self._require_range_version(response.get("modified_at")) + # Request only continuing ranges; complete ranges already remain in responses. + offsets = following + raise ValueError("Wispr body exceeds the bounded capture page limit") + + def child_scope(self, parent: SourceRecord, record_type: str) -> CompletedScope: + """Keep each body attached to its exact native listing observation.""" + if self.canonical_container_parents.get(record_type) != parent.identity.record_type: + raise ValueError("Unsupported Wispr child scope") + # Preserve existing provider-global body identity, including null container. + return CompletedScope(record_type=record_type, parent=parent.identity) + + async def confirm_absent(self, record: SourceRecord) -> None: + """Wispr supplies no audited absence or revocation proof.""" + raise ValueError("Wispr listing omission is not deletion or revocation evidence") + + async def capture_page( + self, + scope: CompletedScope, + continuation: ScanContinuation, + *, + files: FileService, + parent: SourceRecord | None = None, + ) -> CapturePage: + """Commit listing pages and individual bodies through one durable frontier. + + Completed bodies remain observed state in this cycle even if a later attempt + refreshes listing metadata. The next cycle refreshes them; this is no snapshot. + """ + kind = scope.record_type + if ( + kind in {"meeting_listing", "scratchpad_listing"} + and scope.parent is None + and parent is None + and scope.container_id is None + ): + return await self._listing_page(kind, continuation) + if ( + kind not in {"meeting", "scratchpad_note"} + or parent is None + or self.canonical_container_parents.get(kind) != parent.identity.record_type + or scope.parent != parent.identity + or scope.container_id is not None + or continuation.value + ): + raise ValueError("Unsupported Wispr body scope or continuation") + identity = _Listing.model_validate(parent.payload).id + if identity != parent.identity.native_id: + raise ValueError("Wispr listing identity does not match its scope") + payload = await self._body(kind, identity) + payload["listing"] = parent.payload + self._archive_size(payload) + modified = _Version.model_validate(payload["responses"][0]["response"]).modified_at + return CapturePage( + records=( + CaptureRecord( + identity=RecordIdentity(record_type=kind, native_id=identity), + parent=parent.identity, + payload=payload, + completeness="partial", + source_updated_at=modified, + observed_at=datetime.now(timezone.utc), + ), + ), + continuation=ScanContinuation(), + final=True, + ) + + async def _listing_page( + self, kind: Literal["meeting_listing", "scratchpad_listing"], continuation: ScanContinuation + ) -> CapturePage: + progress = _Progress.model_validate(continuation.value) + arguments: dict[str, JsonValue] = {"limit": 200} + if progress.cursor: + arguments["cursor"] = progress.cursor + if progress.window.since: + arguments["since"] = progress.window.since.isoformat() + if progress.window.until: + arguments["until"] = progress.window.until.isoformat() + if kind == "meeting_listing": + page = MeetingPage.model_validate( + await self._execute("WISPR_FLOW_MCP_SEARCH_MEETINGS", arguments) + ) + rows = page.meetings + dates = [_MeetingStart.model_validate(row).start for row in rows] + else: + page = ScratchpadPage.model_validate( + await self._execute("WISPR_FLOW_MCP_SEARCH_SCRATCHPAD_NOTES", arguments) + ) + rows = page.notes + dates = [_Version.model_validate(row).modified_at for row in rows] + next_progress, final = self._advance_listing(progress, page, dates) + identities = [_Listing.model_validate(row).id for row in rows] + if len(identities) != len(set(identities)): + raise ValueError("Wispr listing page repeats a native identity") + records = tuple( + CaptureRecord( + identity=RecordIdentity(record_type=kind, native_id=identity), + payload=row, + completeness="metadata_only", + observed_at=datetime.now(timezone.utc), + ) + for identity, row in zip(identities, rows, strict=True) + ) + return CapturePage( + records=records, + continuation=ScanContinuation(value=next_progress.model_dump(mode="json")), + final=final, + ) + + def _advance_listing( + self, + progress: _Progress, + page: MeetingPage | ScratchpadPage, + dates: list[datetime | None], + ) -> tuple[_Progress, bool]: + """Advance native cursors and capped date windows without a completeness claim.""" + count = progress.count + len(dates) + following = page.next_cursor if page.has_more else None + dates_complete = progress.dates_complete and all(value is not None for value in dates) + known_dates = [value for value in dates if value is not None] + [ + d for d in (progress.earliest, progress.latest) if d is not None + ] + earliest = min(known_dates) if known_dates else None + latest = max(known_dates) if known_dates else None + capped = page.truncated or count > 1000 or (page.has_more and count >= 1000) + if capped: + if not dates_complete: + raise ValueError("Wispr query cap cannot be partitioned safely") + next_progress = self._partition(progress, earliest, latest) + final = False + elif page.has_more: + following_digest = ( + hashlib.sha256(following.encode()).hexdigest()[:32] if following else None + ) + if not following or following == progress.cursor or following_digest in progress.seen: + raise ValueError("Wispr returned incomplete or repeated pagination") + next_progress = _Progress.model_validate( + { + **progress.model_dump(), + "dates_complete": dates_complete, + "cursor": following, + "count": count, + "earliest": earliest, + "latest": latest, + "seen": [*progress.seen, following_digest], + } + ) + final = False + elif progress.pending: + next_progress = _Progress(window=progress.pending[-1], pending=progress.pending[:-1]) + final = False + else: + next_progress = _Progress() + final = True + return next_progress, final + + @staticmethod + def _partition( + progress: _Progress, earliest: datetime | None, latest: datetime | None + ) -> _Progress: + if earliest is None or latest is None or earliest == latest or progress.window.depth >= 32: + raise ValueError("Wispr query cap cannot be partitioned safely") + split = earliest + (latest - earliest) / 2 + window = progress.window + if (window.since is not None and split <= window.since) or ( + window.until is not None and split >= window.until + ): + raise ValueError("Wispr date partition made no progress") + return _Progress( + window=_Window(since=window.since, until=split, depth=window.depth + 1), + pending=[ + *progress.pending, + _Window(since=split, until=window.until, depth=window.depth + 1), + ], + ) diff --git a/backend/airweave/platform/sources/word.py b/backend/airweave/platform/sources/word.py index 8a4178b66d..db7abc96ed 100644 --- a/backend/airweave/platform/sources/word.py +++ b/backend/airweave/platform/sources/word.py @@ -29,12 +29,12 @@ from airweave.platform.entities._base import BaseEntity from airweave.platform.entities.word import WORD_EXTENSIONS, WordDocumentEntity from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/zendesk.py b/backend/airweave/platform/sources/zendesk.py index 2f9ffafd58..329836bd75 100644 --- a/backend/airweave/platform/sources/zendesk.py +++ b/backend/airweave/platform/sources/zendesk.py @@ -25,12 +25,12 @@ _parse_dt, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/platform/sources/zoho_crm.py b/backend/airweave/platform/sources/zoho_crm.py index 70376e0e0b..6c6982d4ca 100644 --- a/backend/airweave/platform/sources/zoho_crm.py +++ b/backend/airweave/platform/sources/zoho_crm.py @@ -37,12 +37,12 @@ ZohoCRMSalesOrderEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType ZOHO_API_LIMIT = 200 diff --git a/backend/airweave/platform/sources/zoom.py b/backend/airweave/platform/sources/zoom.py index fa2004f3cc..6fb7fef55f 100644 --- a/backend/airweave/platform/sources/zoom.py +++ b/backend/airweave/platform/sources/zoom.py @@ -37,12 +37,12 @@ ZoomTranscriptEntity, ) from airweave.platform.http_client.airweave_client import AirweaveHttpClient -from airweave.platform.sources._base import BaseSource -from airweave.platform.sources.http_helpers import raise_for_status -from airweave.platform.sources.retry_helpers import ( +from airweave.platform.http_client.retry_helpers import ( retry_if_rate_limit_or_timeout, wait_rate_limit_with_backoff, ) +from airweave.platform.sources._base import BaseSource +from airweave.platform.sources.http_helpers import raise_for_status from airweave.schemas.source_connection import AuthenticationMethod, OAuthType diff --git a/backend/airweave/schemas/entity.py b/backend/airweave/schemas/entity.py index 1ec1e6d0bf..82e8809fa2 100644 --- a/backend/airweave/schemas/entity.py +++ b/backend/airweave/schemas/entity.py @@ -38,6 +38,7 @@ class EntityUpdate(BaseModel): class EntityInDBBase(EntityBase): """Base schema for Entity stored in DB.""" + sync_job_id: UUID | None id: UUID organization_id: UUID created_at: datetime diff --git a/backend/airweave/schemas/search_v2.py b/backend/airweave/schemas/search_v2.py index 9eaa2d9537..8691bd48f5 100644 --- a/backend/airweave/schemas/search_v2.py +++ b/backend/airweave/schemas/search_v2.py @@ -42,7 +42,7 @@ class InstantSearchRequest(BaseModel): filter: Optional[list[FilterGroup]] = Field( default=None, description="Filter groups (combined with OR)." ) - limit: int = Field(default=100, ge=1, le=1000, description="Max results to return.") + limit: int = Field(default=100, ge=1, le=200, description="Max results to return.") offset: int = Field(default=0, ge=0, description="Number of results to skip.") @field_validator("query") @@ -70,7 +70,7 @@ class ClassicSearchRequest(BaseModel): filter: Optional[list[FilterGroup]] = Field( default=None, description="Filter groups (combined with OR)." ) - limit: int = Field(default=100, ge=1, le=1000, description="Max results to return.") + limit: int = Field(default=100, ge=1, le=200, description="Max results to return.") offset: int = Field(default=0, ge=0, description="Number of results to skip.") @field_validator("query") @@ -185,7 +185,10 @@ class BrowseResponse(BaseModel): results: list[SearchResult] = Field( default_factory=list, description="Rows on this page (one per source entity)." ) - total: int = Field(..., description="Total entity count matching the filter.") + total: int | None = Field( + ..., description="Exact visible count, or null when the index cannot prove it." + ) + retrieval_incomplete: bool = False limit: int = Field(..., description="Limit echoed back from the request.") offset: int = Field(..., description="Offset echoed back from the request.") @@ -286,3 +289,11 @@ class SearchV2Response(BaseModel): results: list[SearchResult] = Field( default_factory=list, description="Search results ordered by relevance." ) + + engine_partial: bool = False + engine_coverage_percent: float | None = None + + retrieval_incomplete: bool = Field( + default=False, description="Unavailable index candidates may leave this page incomplete." + ) + excluded_candidates: int = Field(default=0, ge=0) diff --git a/backend/airweave/schemas/sync.py b/backend/airweave/schemas/sync.py index 8738aa362d..ec8ec82149 100644 --- a/backend/airweave/schemas/sync.py +++ b/backend/airweave/schemas/sync.py @@ -126,6 +126,8 @@ class ScheduleResponse(BaseModel): class SyncInDBBase(SyncBase): """Base schema for Sync stored in DB.""" + provisioning_generation: int = Field(default=0, ge=0) + provisioning_ready_generation: int = Field(default=0, ge=0) id: UUID organization_id: UUID created_at: datetime diff --git a/backend/airweave/schemas/sync_job.py b/backend/airweave/schemas/sync_job.py index 1d5b3c16a4..fd632031dd 100644 --- a/backend/airweave/schemas/sync_job.py +++ b/backend/airweave/schemas/sync_job.py @@ -68,6 +68,7 @@ class SyncJobUpdate(BaseModel): class SyncJobInDBBase(SyncJobBase): """Base schema for SyncJob stored in DB.""" + provisioning_generation: int = Field(default=0, ge=0) id: UUID organization_id: UUID created_by_email: Optional[EmailStr] = None diff --git a/backend/airweave/search/operations/embed_query.py b/backend/airweave/search/operations/embed_query.py index 15b2b663e5..a3a1d76c9d 100644 --- a/backend/airweave/search/operations/embed_query.py +++ b/backend/airweave/search/operations/embed_query.py @@ -110,7 +110,7 @@ async def _generate_dense_embeddings( f"[EmbedQuery] Generating {self.dense_embedder.dimensions}-dim embeddings " f"for {len(queries)} queries" ) - results = await self.dense_embedder.embed_many(queries) + results = await self.dense_embedder.embed_many(queries, purpose="query") # Validate we got embeddings for all queries if len(results) != len(queries): diff --git a/backend/alembic/versions/0001_canonical_records.py b/backend/alembic/versions/0001_canonical_records.py new file mode 100644 index 0000000000..c439d2ff7b --- /dev/null +++ b/backend/alembic/versions/0001_canonical_records.py @@ -0,0 +1,107 @@ +"""Canonical source records, fenced writers and observed change snapshots. + +Revision ID: 0001 +Revises: 0000 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0001" +down_revision = "0000" +branch_labels = None +depends_on = None + + +def upgrade(): + """Legacy metadata remains revision zero until authoritative recapture.""" + op.drop_constraint("fk_entity_sync_job_id", "entity", type_="foreignkey") + op.alter_column("entity", "sync_job_id", nullable=True) + op.create_foreign_key( + "fk_entity_sync_job_id", + "entity", + "sync_job", + ["sync_job_id"], + ["id"], + ondelete="SET NULL", + ) + for column in ( + sa.Column("native_id", sa.String(), nullable=True), + sa.Column("container_id", sa.String(), nullable=True), + sa.Column("parent_record_type", sa.String(), nullable=True), + sa.Column("parent_native_id", sa.String(), nullable=True), + sa.Column("parent_container_id", sa.String(), nullable=True), + sa.Column("source_payload", postgresql.JSONB(), nullable=True), + sa.Column("payload_schema_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("record_revision", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("capture_hash", sa.String(), nullable=True), + sa.Column("content_hash", sa.String(), nullable=True), + sa.Column("source_created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("removal_reason", sa.String(), nullable=True), + sa.Column("completeness", sa.String(), nullable=True), + sa.Column("blob_references", postgresql.JSONB(), nullable=True), + sa.Column("last_seen_run_id", sa.UUID(), nullable=True), + sa.Column("indexed_revision", sa.BigInteger(), nullable=True), + sa.Column("indexed_pipeline_version", sa.BigInteger(), nullable=True), + sa.Column("projection_error", sa.String(), nullable=True), + ): + op.add_column("entity", column) + op.create_index( + "idx_entity_canonical_scope", + "entity", + ["sync_id", "entity_definition_short_name", "container_id"], + ) + op.create_index( + "idx_entity_pending_revision", + "entity", + ["sync_id", "id"], + postgresql_where=sa.text( + "record_revision > 0 AND indexed_revision IS DISTINCT FROM record_revision" + ), + ) + for column in ( + sa.Column("observed_change_sequence", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("writer_epoch", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("writer_job_id", sa.UUID(), nullable=True), + sa.Column("writer_attempt_id", sa.UUID(), nullable=True), + sa.Column("writer_attempt_number", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("index_pipeline_version", sa.BigInteger(), nullable=False, server_default="1"), + ): + op.add_column("sync", column) + op.create_table( + "entity_change", + sa.Column("id", sa.UUID(), nullable=False, primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column( + "organization_id", + sa.UUID(), + sa.ForeignKey("organization.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sync_id", sa.UUID(), sa.ForeignKey("sync.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "entity_record_id", + sa.UUID(), + sa.ForeignKey("entity.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("sequence", sa.BigInteger(), nullable=False), + sa.Column("record_revision", sa.BigInteger(), nullable=False), + sa.Column("kind", sa.String(), nullable=False), + sa.Column("snapshot", postgresql.JSONB(), nullable=False), + sa.UniqueConstraint("sync_id", "sequence", name="uq_entity_change_sequence"), + ) + op.create_index("ix_entity_change_organization_id", "entity_change", ["organization_id"]) + + +def downgrade(): + """Refuse to destroy canonical source state through an accidental downgrade.""" + raise RuntimeError("Canonical record migration is forward-only; restore a verified backup") diff --git a/backend/alembic/versions/0002_projection_publication.py b/backend/alembic/versions/0002_projection_publication.py new file mode 100644 index 0000000000..44025b9664 --- /dev/null +++ b/backend/alembic/versions/0002_projection_publication.py @@ -0,0 +1,26 @@ +"""Revision-safe canonical index publication pointer. + +Revision ID: 0002 +Revises: 0001 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add a publication pointer; old index state remains untrusted until rebuilt.""" + op.add_column("entity", sa.Column("indexed_generation", sa.Uuid(), nullable=True)) + op.add_column("entity", sa.Column("indexed_chunk_count", sa.BigInteger(), nullable=True)) + + +def downgrade(): + """Remove only derived publication state, preserving source records.""" + op.drop_column("entity", "indexed_chunk_count") + op.drop_column("entity", "indexed_generation") diff --git a/backend/alembic/versions/0003_mail_thread_index.py b/backend/alembic/versions/0003_mail_thread_index.py new file mode 100644 index 0000000000..09eb966ba6 --- /dev/null +++ b/backend/alembic/versions/0003_mail_thread_index.py @@ -0,0 +1,24 @@ +"""Index account-scoped Gmail thread traversal over original provider JSON.""" + +from alembic import op + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Keep thread lookup proportional to one account/thread, not its entire mailbox.""" + op.execute(""" + CREATE INDEX ix_entity_mail_thread ON entity + (organization_id, sync_id, (source_payload ->> 'threadId'), + source_created_at ASC NULLS LAST, id) + WHERE entity_definition_short_name = 'message' + AND record_revision > 0 AND deleted_at IS NULL + """) + + +def downgrade() -> None: + """Drop only the derived lookup index; canonical payloads remain unchanged.""" + op.drop_index("ix_entity_mail_thread", table_name="entity") diff --git a/backend/alembic/versions/0004_projection_generation.py b/backend/alembic/versions/0004_projection_generation.py new file mode 100644 index 0000000000..af1d80c850 --- /dev/null +++ b/backend/alembic/versions/0004_projection_generation.py @@ -0,0 +1,47 @@ +"""Durable prepared feed manifests and repeatable retired-generation cleanup. + +Revision ID: 0004 +Revises: 0003 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade(): + """Keep body-free ownership IDs without cascading FKs so deletion survives source removal.""" + op.create_table( + "projection_generation", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + *( + sa.Column(name, sa.Uuid(), nullable=False) + for name in ("organization_id", "sync_id", "collection_id", "record_id") + ), + sa.Column("revision", sa.BigInteger(), nullable=False), + sa.Column("pipeline_version", sa.BigInteger(), nullable=False), + sa.Column("documents", postgresql.JSONB(), nullable=False), + sa.Column("retired_at", sa.DateTime(timezone=True)), + sa.Column("next_gc_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_gc_at", sa.DateTime(timezone=True)), + sa.Column("delete_cursor", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("gc_passes", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("gc_attempt", sa.Uuid()), + sa.Column("gc_error", sa.String(200)), + ) + for name in ("organization_id", "sync_id", "record_id", "next_gc_at"): + op.create_index(f"ix_projection_generation_{name}", "projection_generation", [name]) + op.create_index("idx_projection_gc_due", "projection_generation", ["next_gc_at", "id"]) + + +def downgrade(): + """Do not silently discard cleanup obligations.""" + raise RuntimeError("Projection generation manifests require explicit cleanup before removal") diff --git a/backend/alembic/versions/0005_capture_scan.py b/backend/alembic/versions/0005_capture_scan.py new file mode 100644 index 0000000000..d87af234f1 --- /dev/null +++ b/backend/alembic/versions/0005_capture_scan.py @@ -0,0 +1,56 @@ +"""Durable whole-scope capture progress. + +Revision ID: 0005 +Revises: 0004 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade(): + """Keep one bounded continuation per source scope, not per job.""" + op.create_table( + "capture_scan", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column( + "organization_id", + sa.Uuid(), + sa.ForeignKey("organization.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sync_id", sa.Uuid(), sa.ForeignKey("sync.id", ondelete="CASCADE"), nullable=False + ), + sa.Column("scope_key", sa.String(), nullable=False), + sa.Column("record_type", sa.String(), nullable=False), + sa.Column("container_id", sa.String()), + sa.Column("cycle_id", sa.Uuid(), nullable=False), + sa.Column("sweep_id", sa.Uuid(), nullable=False), + sa.Column("revision", sa.BigInteger(), nullable=False), + sa.Column("phase", sa.String(), nullable=False), + sa.Column("fingerprint", sa.String(64), nullable=False), + sa.Column("continuation", postgresql.JSONB(), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.UniqueConstraint("sync_id", "scope_key", name="uq_capture_scan_scope"), + sa.CheckConstraint( + "phase IN ('collecting','reconciling','complete')", name="ck_capture_scan_phase" + ), + sa.CheckConstraint("revision >= 1", name="ck_capture_scan_revision"), + ) + op.create_index("ix_capture_scan_organization_id", "capture_scan", ["organization_id"]) + + +def downgrade(): + """Active recovery obligations require an explicit operator decision.""" + raise RuntimeError("Capture scans require explicit completion or abandonment before removal") diff --git a/backend/alembic/versions/0006_record_visibility.py b/backend/alembic/versions/0006_record_visibility.py new file mode 100644 index 0000000000..9399238520 --- /dev/null +++ b/backend/alembic/versions/0006_record_visibility.py @@ -0,0 +1,58 @@ +"""Epoch-attested canonical record-parent visibility. + +Revision ID: 0006 +Revises: 0005 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade(): + """Attest only existing visible flat links; never revive withdrawn content.""" + op.add_column( + "entity", sa.Column("visibility_epoch", sa.BigInteger(), nullable=False, server_default="1") + ) + op.add_column("entity", sa.Column("parent_visibility_epoch", sa.BigInteger())) + op.create_check_constraint( + "ck_entity_visibility_epoch", + "entity", + "visibility_epoch >= 1 AND " + "(parent_visibility_epoch IS NULL OR parent_visibility_epoch >= 1)", + ) + op.create_index( + "idx_entity_canonical_identity", + "entity", + ["sync_id", "entity_definition_short_name", "native_id", "container_id"], + ) + op.create_index( + "idx_entity_canonical_parent", + "entity", + ["sync_id", "parent_record_type", "parent_native_id", "parent_container_id"], + ) + op.execute(""" + UPDATE entity child SET parent_visibility_epoch = parent.visibility_epoch + FROM entity parent + WHERE child.organization_id = parent.organization_id + AND child.sync_id = parent.sync_id + AND child.parent_record_type = parent.entity_definition_short_name + AND child.parent_native_id = parent.native_id + AND child.parent_container_id IS NOT DISTINCT FROM parent.container_id + AND child.record_revision > 0 AND parent.record_revision > 0 + AND parent.parent_record_type IS NULL AND parent.deleted_at IS NULL + AND (parent.removal_reason IS NULL + OR parent.removal_reason NOT IN ('scope_removed', 'access_revoked')) + AND (child.removal_reason IS NULL + OR child.removal_reason NOT IN ('scope_removed', 'access_revoked')) + """) + + +def downgrade(): + """Removing visibility attestations requires an explicit operator decision.""" + raise RuntimeError("Record visibility cannot safely downgrade to flat parent checks") diff --git a/backend/alembic/versions/0007_scan_scope_owner.py b/backend/alembic/versions/0007_scan_scope_owner.py new file mode 100644 index 0000000000..6c2b772e23 --- /dev/null +++ b/backend/alembic/versions/0007_scan_scope_owner.py @@ -0,0 +1,171 @@ +"""Exact parent ownership for nested capture scopes. + +Revision ID: 0007 +Revises: 0006 +""" + +import json +from typing import Annotated, Literal +from uuid import UUID + +import sqlalchemy as sa +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictInt, + StrictStr, + ValidationError, + model_validator, +) + +from alembic import op + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + +Kind = Annotated[StrictStr, Field(min_length=1, max_length=200)] + + +class LegacyConfiguration(BaseModel): + """Frozen schema1 migration contract, independent of future runtime models.""" + + model_config = ConfigDict(extra="forbid") + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + root_record_type: Kind + child_record_types: list[Kind] = Field(default_factory=list, max_length=20) + + @model_validator(mode="after") + def distinct(self): + """A flat root cannot be its own child or have duplicate child declarations.""" + if self.root_record_type in self.child_record_types or len( + set(self.child_record_types) + ) != len(self.child_record_types): + raise ValueError("Invalid legacy flat topology") + return self + + +class LegacyVersion(BaseModel): + """Exact existing checkpoint boundary.""" + + model_config = ConfigDict(extra="forbid") + cycle_id: UUID + revision: StrictInt = Field(ge=1) + + +class LegacyCycle(BaseModel): + """Only active schema1 snapshots authorize ownership backfill.""" + + model_config = ConfigDict(extra="forbid") + schema_version: StrictInt = Field(ge=1, le=1) + version: LegacyVersion + configuration: LegacyConfiguration + phase: Literal["active"] + root_writer_attempt_id: UUID | None = None + completed_job_id: UUID | None = None + + +def bind_legacy_scans(connection): + """Unresolved or malformed rows retain their original bytes for explicit recovery.""" + cursors = connection.execute( + sa.text("SELECT organization_id,sync_id,cursor_data FROM sync_cursor") + ).mappings() + for cursor in cursors: + value = cursor["cursor_data"] + if not isinstance(value, dict): + continue + try: + cycle = LegacyCycle.model_validate(value.get("canonical_cycle")) + except ValidationError: + continue + scope = { + "org": cursor["organization_id"], + "sync": cursor["sync_id"], + "cycle": cycle.version.cycle_id, + } + rows = ( + connection.execute( + sa.text(""" + SELECT id,scope_key,record_type,container_id FROM capture_scan + WHERE organization_id=:org AND sync_id=:sync AND cycle_id=:cycle + """), + scope, + ) + .mappings() + .all() + ) + for row in rows: + expected = json.dumps( + [row["record_type"], row["container_id"]], ensure_ascii=False, separators=(",", ":") + ) + if row["scope_key"] != expected: + continue + if ( + row["container_id"] is None + and row["record_type"] == cycle.configuration.root_record_type + ): + connection.execute( + sa.text("UPDATE capture_scan SET membership_attempt_id=:attempt WHERE id=:id"), + {"attempt": cycle.root_writer_attempt_id, "id": row["id"]}, + ) + elif ( + row["container_id"] is not None + and row["record_type"] in cycle.configuration.child_record_types + ): + parent = ( + connection.execute( + sa.text(""" + SELECT id,visibility_epoch FROM entity + WHERE organization_id=:org AND sync_id=:sync + AND entity_definition_short_name=:kind AND native_id=:native + AND container_id IS NULL AND record_revision>0 + """), + { + "org": scope["org"], + "sync": scope["sync"], + "kind": cycle.configuration.root_record_type, + "native": row["container_id"], + }, + ) + .mappings() + .one_or_none() + ) + if parent is not None: + connection.execute( + sa.text(""" + UPDATE capture_scan SET parent_record_id=:parent, + parent_visibility_epoch=:epoch, + scope_key=:key WHERE id=:id + """), + { + "parent": parent["id"], + "epoch": parent["visibility_epoch"], + "key": expected + "|" + str(parent["id"]), + "id": row["id"], + }, + ) + + +def upgrade(): + """Bind old flat scans only to validated, declared tenant-scoped root records.""" + op.add_column( + "capture_scan", + sa.Column("parent_record_id", sa.Uuid(), sa.ForeignKey("entity.id", ondelete="CASCADE")), + ) + op.add_column("capture_scan", sa.Column("parent_visibility_epoch", sa.BigInteger())) + op.add_column("capture_scan", sa.Column("membership_attempt_id", sa.Uuid())) + bind_legacy_scans(op.get_bind()) + op.create_index( + "idx_capture_scan_parent", + "capture_scan", + ["sync_id", "parent_record_id", "record_type"], + unique=True, + postgresql_where=sa.text("parent_record_id IS NOT NULL"), + ) + + +def downgrade(): + """Nested recovery cannot safely be interpreted by the flat engine.""" + raise RuntimeError("Nested capture scopes require explicit retirement before downgrade") diff --git a/backend/alembic/versions/0008_scope_execution.py b/backend/alembic/versions/0008_scope_execution.py new file mode 100644 index 0000000000..c6e9483c97 --- /dev/null +++ b/backend/alembic/versions/0008_scope_execution.py @@ -0,0 +1,21 @@ +"""Retain completed scope evidence independently of current page progress.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0008" +down_revision = "0007" +branch_labels = None +depends_on = None + + +def upgrade(): + """Keep existing rows explicitly without attested mixed-scope history.""" + op.add_column("capture_scan", sa.Column("execution_state", postgresql.JSONB(), nullable=True)) + + +def downgrade(): + """Remove mixed execution evidence only on an explicit schema downgrade.""" + op.drop_column("capture_scan", "execution_state") diff --git a/backend/alembic/versions/0009_extraction_coverage.py b/backend/alembic/versions/0009_extraction_coverage.py new file mode 100644 index 0000000000..4763ae5845 --- /dev/null +++ b/backend/alembic/versions/0009_extraction_coverage.py @@ -0,0 +1,23 @@ +"""Generation-owned extraction coverage; legacy publications remain unknown.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade(): + """Old generations remain explicitly unknown until reprojection.""" + op.add_column( + "projection_generation", sa.Column("extraction_coverage", postgresql.JSONB(), nullable=True) + ) + + +def downgrade(): + """Remove derived evidence only on explicit downgrade.""" + op.drop_column("projection_generation", "extraction_coverage") diff --git a/backend/alembic/versions/0010_owned_provisioning.py b/backend/alembic/versions/0010_owned_provisioning.py new file mode 100644 index 0000000000..086b489fae --- /dev/null +++ b/backend/alembic/versions/0010_owned_provisioning.py @@ -0,0 +1,73 @@ +"""Durable account provisioning and generation-fenced capture admission.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade(): + """Existing sources/jobs remain unmanaged generation zero.""" + for table, column in ( + ("sync", "provisioning_generation"), + ("sync", "provisioning_ready_generation"), + ("sync_job", "provisioning_generation"), + ): + op.add_column(table, sa.Column(column, sa.BigInteger(), nullable=False, server_default="0")) + op.create_table( + "owned_provisioning", + sa.Column("id", sa.UUID(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column( + "organization_id", + sa.UUID(), + sa.ForeignKey("organization.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("client_namespace", sa.String(32), nullable=False), + sa.Column("account_id", sa.UUID(), nullable=False), + sa.Column("generation", sa.BigInteger(), nullable=False), + sa.Column("request_hash", sa.String(64), nullable=False), + sa.Column("request_payload", postgresql.JSONB(), nullable=False), + sa.Column("desired_state", sa.String(20), nullable=False), + sa.Column("observed_generation", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column( + "source_connection_id", + sa.UUID(), + sa.ForeignKey("source_connection.id", ondelete="RESTRICT"), + ), + sa.Column("sync_id", sa.UUID(), sa.ForeignKey("sync.id", ondelete="RESTRICT")), + sa.Column("initial_job_id", sa.UUID(), sa.ForeignKey("sync_job.id", ondelete="SET NULL")), + sa.Column("cancellation_job_ids", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column("verified_at", sa.DateTime()), + sa.UniqueConstraint( + "organization_id", + "client_namespace", + "account_id", + name="uq_owned_provisioning_account", + ), + sa.UniqueConstraint("sync_id", name="uq_owned_provisioning_sync"), + sa.CheckConstraint( + "generation > 0 AND observed_generation >= 0 AND observed_generation <= generation", + name="ck_owned_provisioning_generation", + ), + sa.CheckConstraint("client_namespace = 'almanac'", name="ck_owned_provisioning_namespace"), + sa.CheckConstraint( + "desired_state IN ('active', 'paused', 'disconnected')", + name="ck_owned_provisioning_state", + ), + ) + + +def downgrade(): + """Only explicit rollback removes generation protections.""" + op.drop_table("owned_provisioning") + op.drop_column("sync_job", "provisioning_generation") + op.drop_column("sync", "provisioning_ready_generation") + op.drop_column("sync", "provisioning_generation") diff --git a/backend/alembic/versions/0011_retained_projection_text.py b/backend/alembic/versions/0011_retained_projection_text.py new file mode 100644 index 0000000000..a8d32c1144 --- /dev/null +++ b/backend/alembic/versions/0011_retained_projection_text.py @@ -0,0 +1,28 @@ +"""Retain derived text descriptors with their projection publication generation.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade(): + """Old generations have unknown representation coverage until reprojected.""" + op.add_column( + "projection_generation", + sa.Column( + "text_representations", + postgresql.JSONB(none_as_null=True), + nullable=True, + ), + ) + + +def downgrade(): + """Remove descriptors; no source originals are affected.""" + op.drop_column("projection_generation", "text_representations") diff --git a/backend/alembic/versions/0012_scan_parent_verification.py b/backend/alembic/versions/0012_scan_parent_verification.py new file mode 100644 index 0000000000..46e5253ef0 --- /dev/null +++ b/backend/alembic/versions/0012_scan_parent_verification.py @@ -0,0 +1,28 @@ +"""Exact owner verification on the existing scope scan. + +Revision ID: 0012 +Revises: 0011 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +def upgrade(): + """Old scans have no verification; never backfill unproven permission.""" + op.add_column("capture_scan", sa.Column("parent_verified_attempt_id", sa.Uuid(), nullable=True)) + op.add_column( + "capture_scan", sa.Column("parent_verified_revision", sa.BigInteger(), nullable=True) + ) + + +def downgrade(): + """Remove only admission receipts, not captured originals.""" + op.drop_column("capture_scan", "parent_verified_revision") + op.drop_column("capture_scan", "parent_verified_attempt_id") diff --git a/backend/entrypoint.dev.sh b/backend/entrypoint.dev.sh index 6e018bdbd5..80e49f5d4c 100644 --- a/backend/entrypoint.dev.sh +++ b/backend/entrypoint.dev.sh @@ -21,11 +21,10 @@ def check_db(): check_db() " -# Run migrations using our existing Alembic setup -echo "Running database migrations..." -cd /app && poetry run alembic upgrade head -# cd /app #dev time to not rerun alembic on conflicting branches +echo "Starting against the provisioned database..." +cd /app +# Migrations and bootstrap are explicit deployment jobs, never replica startup. # Start application with hot reloading enabled echo "Starting application with hot reloading..." -poetry run uvicorn airweave.main:app --host 0.0.0.0 --port 8001 --reload --reload-dir /app/airweave +poetry run uvicorn airweave.main:app --host 0.0.0.0 --port 8001-dir /app/airweave diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 00457d17bc..5284e3fca5 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,56 +1,7 @@ -#!/bin/bash -set -e +#!/bin/sh +set -eu -# Wait for database to be ready -echo "Waiting for database..." -echo "POSTGRES_HOST: $POSTGRES_HOST" -echo "POSTGRES_USER: $POSTGRES_USER" -echo "POSTGRES_DB: $POSTGRES_DB" -echo "POSTGRES_PORT: ${POSTGRES_PORT:-5432}" -echo "POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-prefer}" - -poetry run python -c " -from tenacity import retry, stop_after_attempt, wait_fixed -from sqlalchemy import create_engine -import os -import sys - -@retry(stop=stop_after_attempt(15), wait=wait_fixed(5)) -def check_db(): - try: - user = os.environ['POSTGRES_USER'] - password = os.environ['POSTGRES_PASSWORD'] - host = os.getenv('POSTGRES_HOST', 'db') - db = os.getenv('POSTGRES_DB', 'airweave') - port = os.getenv('POSTGRES_PORT', '5432') - sslmode = os.getenv('POSTGRES_SSLMODE', 'prefer') - - print(f'Attempting to connect to: postgresql://{user}:***@{host}:{port}/{db}?sslmode={sslmode}') - - # Use SSL mode from environment variable - connection_string = f'postgresql://{user}:{password}@{host}:{port}/{db}?sslmode={sslmode}' - engine = create_engine(connection_string, connect_args={'connect_timeout': 10}) - connection = engine.connect() - connection.close() - print('Database connection successful!') - - except Exception as e: - print(f'Database connection failed: {str(e)}') - print(f'Error type: {type(e).__name__}') - raise e - -try: - check_db() -except Exception as e: - print(f'Final database connection error: {e}') - sys.exit(1) -" - -# Run migrations using our existing Alembic setup -echo "Running database migrations..." -cd /app && poetry run alembic upgrade head -# cd /app #dev time to not rerun alembic on conflicting branches - -# Start application -echo "Starting application..." -poetry run uvicorn airweave.main:app --host 0.0.0.0 --port 8001 --reload +# Database migrations and bootstrap are explicit deployment jobs. Startup checks +# the provisioned schema; the process supervisor handles unavailable dependencies. +# exec delivers termination signals directly to Uvicorn for graceful shutdown. +exec python -m uvicorn airweave.main:app --host 0.0.0.0 --port 8001 diff --git a/backend/pytest.ini b/backend/pytest.ini index 9e11fe9871..1d23b72188 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -14,7 +14,7 @@ log_cli = true log_cli_level = INFO # Set PYTHONPATH -pythonpath = . +pythonpath = . .. # Configure asyncio for async tests asyncio_mode = auto diff --git a/backend/scripts/REPROJECTION.md b/backend/scripts/REPROJECTION.md new file mode 100644 index 0000000000..e16513b180 --- /dev/null +++ b/backend/scripts/REPROJECTION.md @@ -0,0 +1,49 @@ +# Canonical search metadata upgrade + +This is an operator action against an explicitly selected private source store, +not a product CLI or a capture command. It performs no provider reads/writes. + +Deploy the expanded Vespa application schema **and only compatible API/workers** +before changing versions. An old worker can publish a new numeric version without +the new metadata; the number alone cannot prove compatible binaries. Stop/drain +old workers through the normal release procedure, never mid-capture ad hoc. + +Dry-run (default), from `backend/` with the intended staging database configuration: + +``` +python -m scripts.reproject_canonical --organization ORGANIZATION_UUID \ + --sync SYNC_UUID --expect 1 --target 2 +``` + +Inspect the exact authenticated source binding, current version, captured count +(including tombstones), and target-pending count. These counts describe captured +rows, not search hits or active visible rows. Add `--apply` only after review. +No command was applied while preparing this feature. + +Apply locks the existing Sync row, verifies organization/source/collection scope, +and changes its existing `index_pipeline_version`. That immediately invalidates +older publications through the existing SQL visibility fence. Existing pending +projection selection now finds the records; capture checkpoints remain unchanged. +The script starts the existing `ProjectCanonicalRecordsWorkflow` from its first +page. Its normal page/retry/publication behavior remains authoritative. + +The database commit and Temporal launch are **not atomic**. If launch fails, +pending records remain durable. Retry the identical expected/target arguments: +current version must equal exactly expected or target, never an arbitrary later +version. An already running deterministic workflow ID is reused; a completed or +failed run can be restarted to drain remaining pending rows without another bump. +An accepted workflow is not proof that all content became indexed. Unsupported +conversions may remain pending and must be reported separately. + +Date/type-filtered searches reject versions below 2 with `reindex_required` before +retrieval. Following the bump, pending publication coverage remains explicit; +older generations cannot pass SQL validation. Relevance search is still bounded, +not exhaustive, and this change introduces no chronological ordering or cursor. + +New canonical sources initialize their projection version to the current contract +inside the existing sync-creation transaction, before creating a pending job. +Capability resolution uses the same CanonicalSource/CanonicalPageSource protocols +as the capture factory. Legacy sources retain their existing initial version. +The version is an internal repository argument, not a client-controlled SyncCreate +field; existing syncs are never upgraded implicitly. Sync service tests verify +legacy, Gmail and Slack creation paths; this does not prove hosted provisioning. diff --git a/backend/scripts/reproject_canonical.py b/backend/scripts/reproject_canonical.py new file mode 100644 index 0000000000..6fc056f37f --- /dev/null +++ b/backend/scripts/reproject_canonical.py @@ -0,0 +1,68 @@ +"""Dry-run by default; scoped re-projection without provider capture or writes.""" + +import argparse +import asyncio +from uuid import UUID + +from temporalio.common import WorkflowIDReusePolicy +from temporalio.exceptions import WorkflowAlreadyStartedError + +from airweave.core.config import settings +from airweave.db.session import AsyncSessionLocal +from airweave.domains.entities.canonical.reprojection import plan_reprojection +from airweave.domains.temporal.client import get_client +from airweave.domains.temporal.workflows.project_canonical_records import ( + ProjectCanonicalRecordsWorkflow, +) + + +async def run(args: argparse.Namespace) -> None: + """Commit durable pending state, then start/resume the existing Temporal consumer.""" + async with AsyncSessionLocal() as db: + plan = await plan_reprojection( + db, + args.organization, + args.sync, + expected_version=args.expect, + target_version=args.target, + apply=args.apply, + ) + if args.apply: + await db.commit() + else: + await db.rollback() + print(plan.model_dump_json()) + if not args.apply: + return + # Non-atomic by design: a launch failure leaves pending rows durable. Repeat + # the exact expect/target command; don't choose another version to retry. + client = await get_client() + try: + await client.start_workflow( + ProjectCanonicalRecordsWorkflow.run, + args=[str(args.organization), str(args.sync)], + id=plan.workflow_id, + task_queue=settings.TEMPORAL_TASK_QUEUE, + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + ) + except WorkflowAlreadyStartedError as exc: + raise SystemExit( + "Projection is already running. The requested version remains durable; " + "retry this exact command after it finishes to include previously failed records." + ) from exc + print("Projection workflow accepted; this does not mean indexing is complete.") + + +def main() -> None: + """Parse explicit scope and version pair; dry-run unless --apply is supplied.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--organization", type=UUID, required=True) + parser.add_argument("--sync", type=UUID, required=True) + parser.add_argument("--expect", type=int, required=True) + parser.add_argument("--target", type=int, required=True) + parser.add_argument("--apply", action="store_true") + asyncio.run(run(parser.parse_args())) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/smoke_local_ocr.py b/backend/scripts/smoke_local_ocr.py new file mode 100644 index 0000000000..33414fba6a --- /dev/null +++ b/backend/scripts/smoke_local_ocr.py @@ -0,0 +1,32 @@ +"""Qualify packaged OCR weights and the real isolated worker without external inference.""" + +import argparse +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +import pymupdf + +from airweave.domains.ocr.local import LocalOcrProvider + + +async def main(tessdata: str) -> None: + """A raster image ensures this checks OCR rather than a PDF text-layer shortcut.""" + with TemporaryDirectory(prefix="ocr-smoke-") as directory: + path = Path(directory) / "scan.png" + with pymupdf.open() as document: + page = document.new_page(width=420, height=160) + page.insert_text((25, 80), "ALMANAC 7429", fontsize=30) + page.get_pixmap(dpi=150).save(path) + provider = LocalOcrProvider(tessdata_path=tessdata, languages=("eng", "hin")) + results = await provider.convert_batch([str(path)]) + result = results[str(path)] + if result is None or "ALMANAC" not in result or "7429" not in result: + raise RuntimeError("Packaged local OCR did not recover the synthetic identifier") + print("Packaged local OCR passed; no external inference calls") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tessdata", required=True) + asyncio.run(main(parser.parse_args().tessdata)) diff --git a/backend/tests/integration/test_canonical_search_prefilters.py b/backend/tests/integration/test_canonical_search_prefilters.py new file mode 100644 index 0000000000..40c47faa95 --- /dev/null +++ b/backend/tests/integration/test_canonical_search_prefilters.py @@ -0,0 +1,101 @@ +"""Opt-in real Vespa: canonical filtering happens before a bounded result window.""" + +import asyncio +import json +import os +from datetime import datetime, timezone +from uuid import uuid4 + +import httpx +import pytest +from vespa.application import Vespa + +from airweave.core.logging import logger +from airweave.domains.embedders.types import DenseEmbedding, SparseEmbedding +from airweave.domains.entities.canonical.search_metadata import epoch_microseconds +from airweave.domains.entities.canonical.tests.vespa_helpers import deploy_schema +from airweave.domains.search.adapters.vector_db.filter_translator import FilterTranslator +from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB +from airweave.domains.search.owned import OwnedSearchService +from airweave.domains.search.owned_models import OwnedSearchRequest +from airweave.domains.search.types import FilterGroup, SearchPlan, SearchQuery +from airweave.domains.search.types.embeddings import QueryEmbeddings + +pytestmark = pytest.mark.skipif( + os.environ.get("OWNED_VESPA_TEST") != "1", reason="requires disposable real Vespa" +) + + +async def test_prefilter_excludes_over_two_hundred_wrong_dates_before_top_matches(): + async with httpx.AsyncClient(timeout=120) as http: + await deploy_schema(http) + collection, sync = str(uuid4()), uuid4() + vector = [1.0] + [0.0] * 383 + instant = datetime(2026, 1, 1, microsecond=1, tzinfo=timezone.utc) + urls = [] + expected = None + try: + for number in range(204): + identity = f"canonical-prefilter-{uuid4()}" + url = f"http://localhost:8081/document/v1/airweave/base_entity/docid/{identity}" + urls.append(url) + correct = number == 203 + known = number != 201 + fields = { + "entity_id": identity, + "name": "fundraising", + "textual_representation": "fundraising fixture", + "payload": json.dumps({}), + "airweave_system_metadata_collection_id": collection, + "airweave_system_metadata_sync_id": str(sync), + "airweave_system_metadata_original_entity_id": identity, + "airweave_system_metadata_source_name": "google_calendar", + "airweave_system_metadata_entity_type": "GoogleCalendarEventEntity", + "airweave_system_metadata_canonical_record_type": "message" + if number == 202 + else "event", + "airweave_system_metadata_source_created_known": int(known), + "airweave_system_metadata_source_created_us": epoch_microseconds(instant) + if number >= 201 + else 0, + "dense_embedding": {"values": vector}, + "sparse_embedding": {"cells": {"1": 1.0}}, + } + response = await http.post(url, json={"fields": fields}) + assert response.status_code == 200, response.text + if correct: + expected = identity + context = logger.with_context(request_id="canonical-prefilter-test") + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=context, + filter_translator=FilterTranslator(context), + ) + request = OwnedSearchRequest( + query="fundraising", + sync_ids=(sync,), + mode="keyword", + record_types=("event",), + created_after=instant, + ) + compiled = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary="fundraising"), + retrieval_strategy="keyword", + limit=200, + offset=0, + filter_groups=[ + FilterGroup(conditions=OwnedSearchService._prefilters(request, [sync])) + ], + ), + QueryEmbeddings( + dense_embeddings=[DenseEmbedding(vector=vector)], + sparse_embedding=SparseEmbedding(indices=[1], values=[1.0]), + ), + collection, + ) + result = await engine.execute_query(compiled) + assert not result.engine_partial + assert [hit.entity_id for hit in result.results] == [expected] + finally: + await asyncio.gather(*(http.delete(url) for url in urls)) diff --git a/backend/tests/integration/test_owned_vespa.py b/backend/tests/integration/test_owned_vespa.py new file mode 100644 index 0000000000..4097054ec8 --- /dev/null +++ b/backend/tests/integration/test_owned_vespa.py @@ -0,0 +1,444 @@ +"""Real engine smoke using only synthetic records in a disposable Vespa instance. + +Explicit opt-in: OWNED_VESPA_TEST=1. This deploys the repository schema package +and is unsuitable for an existing application or customer index. +""" + +import json +import math +import os +from datetime import datetime, timezone +from uuid import uuid4 + +import httpx +import pytest +from vespa.application import Vespa + +from airweave.core.logging import logger +from airweave.domains.embedders.types import DenseEmbedding, SparseEmbedding +from airweave.domains.entities.canonical.tests.vespa_helpers import deploy_schema +from airweave.domains.search.adapters.vector_db.filter_translator import FilterTranslator +from airweave.domains.search.adapters.vector_db.vespa_client import VespaVectorDB +from airweave.domains.search.adapters.vector_db.vespa_config import ALL_VESPA_SCHEMAS +from airweave.domains.search.types.embeddings import QueryEmbeddings +from airweave.domains.search.types.filters import FilterCondition, FilterGroup +from airweave.domains.search.types.plan import RetrievalStrategy, SearchPlan, SearchQuery + +pytestmark = pytest.mark.skipif( + os.environ.get("OWNED_VESPA_TEST") != "1", reason="requires disposable real Vespa" +) + + +@pytest.mark.asyncio +async def test_real_schema_retrieval_collection_isolation_and_delete(): + """Exercise real query compilation/rank profiles; vectors are fixed fixtures.""" + async with httpx.AsyncClient(timeout=120) as http: + await deploy_schema(http) + collection, other = str(uuid4()), str(uuid4()) + sync = str(uuid4()) + vector = [1.0] + [0.0] * 383 + urls = [] + try: + for number, scope in enumerate((collection, other)): + identity = f"owned-smoke-{uuid4()}" + url = f"http://localhost:8081/document/v1/airweave/base_entity/docid/{identity}" + urls.append(url) + fields = { + "entity_id": identity, + "created_at": int(datetime(2026, 9, 30, tzinfo=timezone.utc).timestamp()), + "name": "Synthetic fundraising discussion", + "textual_representation": "fundraising discussion fixture", + "payload": json.dumps({"web_url": "https://example.com/fixture"}), + "airweave_system_metadata_collection_id": scope, + "airweave_system_metadata_sync_id": sync, + "airweave_system_metadata_source_name": "gmail", + "airweave_system_metadata_entity_type": "GmailMessageEntity", + "airweave_system_metadata_original_entity_id": identity, + "dense_embedding": {"values": vector}, + "sparse_embedding": {"cells": {"1": 1.0}}, + } + fed = await http.post(url, json={"fields": fields}) + assert fed.status_code == 200, fed.text + if number == 0: + expected_id = identity + contextual = logger.with_context(request_id="synthetic-vespa-smoke") + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=contextual, + filter_translator=FilterTranslator(logger=contextual), + ) + embeddings = QueryEmbeddings( + dense_embeddings=[DenseEmbedding(vector=vector)], + sparse_embedding=SparseEmbedding(indices=[1], values=[1.0]), + ) + for mode in RetrievalStrategy: + compiled = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary="fundraising"), + retrieval_strategy=mode, + limit=20, + offset=0, + ), + embeddings, + collection, + ) + result = await engine.execute_query(compiled) + assert not result.engine_partial + assert [hit.entity_id for hit in result.results] == [expected_id] + assert result.results[0].textual_representation == "fundraising discussion fixture" + for operator, expected_ids in ( + ("greater_than_or_equal", []), + ("less_than", [expected_id]), + ): + filtered = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary="fundraising"), + retrieval_strategy=RetrievalStrategy.KEYWORD, + filter_groups=[ + FilterGroup( + conditions=[ + FilterCondition( + field="created_at", + operator=operator, + value="2026-09-30T00:00:00.500Z", + ) + ] + ) + ], + limit=20, + offset=0, + ), + embeddings, + collection, + ) + result = await engine.execute_query(filtered) + assert not result.engine_partial + assert [hit.entity_id for hit in result.results] == expected_ids + deleted = await http.delete(urls[0]) + assert deleted.status_code == 200 + result = await engine.execute_query(compiled) + assert result.results == [] + finally: + for url in urls: + response = await http.delete(url) + assert response.status_code in (200, 404) + + +@pytest.mark.asyncio +async def test_all_schemas_retrieve_varied_vectors_with_collection_and_sync_scope(): + """Small all-schema recall regression; exact ID sets, not relevance-quality claims.""" + collection, foreign_collection, sync, foreign_sync = (str(uuid4()) for _ in range(4)) + filters = [ + FilterGroup( + conditions=[ + FilterCondition( + field="airweave_system_metadata.sync_id", + operator="in", + value=[sync], + ) + ] + ) + ] + contextual = logger.with_context(request_id="synthetic-all-schema-retrieval") + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=contextual, + filter_translator=FilterTranslator(logger=contextual), + ) + urls, expected = [], set() + async with httpx.AsyncClient(timeout=120) as http: + await deploy_schema(http) + try: + for index, schema in enumerate(ALL_VESPA_SCHEMAS): + # Distinct finite, nonzero vectors exercise angular ANN across schemas. + vector = [math.sin((index + 1) * (dimension + 1)) for dimension in range(384)] + for scope, source in ( + (collection, sync), + (foreign_collection, sync), + (collection, foreign_sync), + ): + identity = f"all-schema-{uuid4()}" + url = f"http://localhost:8081/document/v1/airweave/{schema}/docid/{identity}" + urls.append(url) + response = await http.post( + url, + json={ + "fields": { + "entity_id": identity, + "name": "Synthetic fundraising discussion", + "textual_representation": "fundraising all schema fixture", + "payload": "{}", + "airweave_system_metadata_collection_id": scope, + "airweave_system_metadata_sync_id": source, + "airweave_system_metadata_source_name": "gmail", + "airweave_system_metadata_entity_type": "SyntheticEntity", + "airweave_system_metadata_original_entity_id": identity, + "dense_embedding": {"values": vector}, + "sparse_embedding": {"cells": {"1": 1.0}}, + } + }, + ) + assert response.status_code == 200, response.text + if scope == collection and source == sync: + expected.add(identity) + vectors = ( + [1.0] * 384, + [-1.0] * 384, + [math.cos(dimension + 1) for dimension in range(384)], + ) + for mode in RetrievalStrategy: + for vector in vectors if mode != RetrievalStrategy.KEYWORD else vectors[:1]: + compiled = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary="fundraising"), + retrieval_strategy=mode, + filter_groups=filters, + limit=20, + offset=0, + ), + QueryEmbeddings( + dense_embeddings=[DenseEmbedding(vector=vector)], + sparse_embedding=SparseEmbedding(indices=[1], values=[1.0]), + ), + collection, + ) + params = compiled.raw["params"] + if mode == RetrievalStrategy.KEYWORD: + assert "ranking.matching.filterFirstThreshold" not in params + else: + assert params["ranking.matching.filterFirstThreshold"] == 0.0 + # Force this fixture through ANN even when its random scope is tiny. + # Mutate the actual executable dictionary: raw is PrivateAttr-backed. + params["ranking.matching.approximateThreshold"] = 0.0 + result = await engine.execute_query(compiled) + assert not result.engine_partial, (mode, result.engine_coverage_percent) + assert {item.entity_id for item in result.results} == expected, mode + assert len(result.results) == len(ALL_VESPA_SCHEMAS) + browsed = await engine.filter_search(filters, collection, limit=20) + assert {item.entity_id for item in browsed} == expected + assert await engine.count(filters, collection) == len(expected) + finally: + # Delete and read back only this run's random synthetic document IDs. + for url in urls: + deleted = await http.delete(url) + assert deleted.status_code in (200, 404), deleted.text + assert (await http.get(url)).status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + os.environ.get("OWNED_MINILM_TEST") != "1", reason="requires disposable pinned MiniLM service" +) +async def test_real_minilm_paraphrase_retrieval_and_exact_keyword(): + """Small synthetic retrieval baseline, not a general search-quality benchmark.""" + import asyncio + + from airweave.domains.embedders.dense.local import LocalDenseEmbedder + from airweave.domains.embedders.sparse.fastembed import FastEmbedSparseEmbedder + + corpus = [ + ( + "funding", + "Investors and venture capital", + "We are raising a seed round to finance the startup. " + "Venture investors will purchase equity in the company.", + ), + ( + "travel", + "Summer holiday", + "Book a seaside hotel for our family vacation. We will swim and relax on the beach.", + ), + ( + "incident", + "Database incident ZXQ4829", + "The database connection pool exhausted its limit. " + "Restarting the server restored normal query latency.", + ), + ( + "food", + "Dinner recipe", + "Roast potatoes with olive oil and garlic. Serve the vegetables with fresh bread.", + ), + ] + collection, foreign = str(uuid4()), str(uuid4()) + embedder = LocalDenseEmbedder(inference_url="http://localhost:8080", dimensions=384) + sparse_embedder = FastEmbedSparseEmbedder(model="Qdrant/bm25") + urls = [] + async with httpx.AsyncClient(timeout=120) as http: + try: + for _attempt in range(60): + try: + health = await http.get("http://localhost:8080/.well-known/ready", timeout=5) + if health.status_code == 204: + break + except httpx.HTTPError: + pass + await asyncio.sleep(2) + else: + pytest.fail("Pinned MiniLM service did not become healthy") + await deploy_schema(http) + vectors = await embedder.embed_many([title + "\n" + text for _, title, text in corpus]) + sparse_vectors = await sparse_embedder.embed_many( + [title + "\n" + text for _, title, text in corpus] + ) + identities = {} + for (key, title, text), vector, sparse in zip( + corpus, vectors, sparse_vectors, strict=True + ): + assert len(vector.vector) == 384 and any(vector.vector) + identity = f"quality-{uuid4()}" + identities[key] = identity + # A duplicate in another collection must not become a result. + for scope, suffix in ((collection, ""), (foreign, "-foreign")): + document_id = identity + suffix + url = f"http://localhost:8081/document/v1/airweave/base_entity/docid/{document_id}" + urls.append(url) + response = await http.post( + url, + json={ + "fields": { + "entity_id": document_id, + "name": title, + "textual_representation": text, + "payload": json.dumps({"web_url": "https://example.com/" + key}), + "airweave_system_metadata_collection_id": scope, + "airweave_system_metadata_sync_id": str(uuid4()), + "airweave_system_metadata_source_name": "gmail", + "airweave_system_metadata_entity_type": "GmailMessageEntity", + "airweave_system_metadata_original_entity_id": document_id, + "dense_embedding": {"values": vector.vector}, + "sparse_embedding": { + "cells": { + str(index): value + for index, value in zip( + sparse.indices, sparse.values, strict=True + ) + } + }, + } + }, + ) + assert response.status_code == 200, response.text + contextual = logger.with_context(request_id="synthetic-minilm-baseline") + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=contextual, + filter_translator=FilterTranslator(logger=contextual), + ) + for query, expected, mode in ( + ( + "How are we obtaining money from equity investors?", + "funding", + RetrievalStrategy.SEMANTIC, + ), + ("How was the database outage resolved?", "incident", RetrievalStrategy.SEMANTIC), + ("ZXQ4829", "incident", RetrievalStrategy.KEYWORD), + ): + embeddings = QueryEmbeddings( + dense_embeddings=[await embedder.embed(query)] + if mode == RetrievalStrategy.SEMANTIC + else None, + sparse_embedding=await sparse_embedder.embed(query) + if mode == RetrievalStrategy.KEYWORD + else None, + ) + compiled = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary=query), retrieval_strategy=mode, limit=4, offset=0 + ), + embeddings, + collection, + ) + results = await engine.execute_query(compiled) + assert not results.engine_partial + assert results.results, (query, "no matches") + assert results.results[0].entity_id == identities[expected], ( + query, + [hit.name for hit in results.results], + ) + assert all(hit.entity_id in identities.values() for hit in results.results) + finally: + await embedder.close() + for url in urls: + response = await http.delete(url) + assert response.status_code in (200, 404) + + +@pytest.mark.asyncio +async def test_dynamic_summary_preserves_full_chunks_and_inherited_fields(): + """Actual default summaries must expose lexical fragments without replacing originals.""" + collection, sync = str(uuid4()), str(uuid4()) + vector = [1.0] + [0.0] * 383 + # The match is beyond the owned service's 2,000-character prefix fallback. + original = "Ordinary introductory context. " * 200 + ( + "Uniquelexicalneedle नमस्ते 原文 concludes this record." + ) + contextual = logger.with_context(request_id="synthetic-dynamic-summary") + engine = VespaVectorDB( + app=Vespa(url="http://localhost", port=8081), + logger=contextual, + filter_translator=FilterTranslator(logger=contextual), + ) + embeddings = QueryEmbeddings( + dense_embeddings=[DenseEmbedding(vector=vector)], + sparse_embedding=SparseEmbedding(indices=[1], values=[1.0]), + ) + urls, expected = [], set() + async with httpx.AsyncClient(timeout=120) as http: + await deploy_schema(http) + try: + for schema in ("file_entity", "email_entity"): + identity = f"snippet-{uuid4()}" + expected.add(identity) + url = f"http://localhost:8081/document/v1/airweave/{schema}/docid/{identity}" + urls.append(url) + fed = await http.post( + url, + json={ + "fields": { + "entity_id": identity, + "name": "Synthetic original", + "payload": "{}", + "url": "https://example.test/original", + "textual_representation": original, + "airweave_system_metadata_collection_id": collection, + "airweave_system_metadata_sync_id": sync, + "airweave_system_metadata_source_name": "gmail", + "airweave_system_metadata_entity_type": "GmailMessageEntity", + "airweave_system_metadata_original_entity_id": identity, + "dense_embedding": {"values": vector}, + "sparse_embedding": {"cells": {"1": 1.0}}, + } + }, + ) + assert fed.status_code == 200, fed.text + for mode in RetrievalStrategy: + compiled = await engine.compile_query( + SearchPlan( + query=SearchQuery(primary="Uniquelexicalneedle"), + retrieval_strategy=mode, + limit=10, + offset=0, + ), + embeddings, + collection, + ) + assert "presentation.summary" not in compiled.raw["params"] + results = await engine.execute_query(compiled) + assert {hit.entity_id for hit in results.results} == expected + for hit in results.results: + assert hit.textual_representation == original + assert hit.airweave_system_metadata.original_entity_id == hit.entity_id + assert hit.airweave_system_metadata.sync_id == sync + assert hit.url == "https://example.test/original" + if mode != RetrievalStrategy.SEMANTIC: + assert hit.query_snippet is not None + assert "uniquelexicalneedle" in hit.query_snippet.lower() + assert len(hit.query_snippet) < len(original) + assert "नमस्ते" in hit.query_snippet and "原文" in hit.query_snippet + # Semantic-only summary contents are engine-dependent; owned + # HTTP tests prove they never replace the semantic fallback. + finally: + for url in urls: + assert (await http.delete(url)).status_code in (200, 404) + assert (await http.get(url)).status_code == 404 diff --git a/backend/tests/live/README.md b/backend/tests/live/README.md new file mode 100644 index 0000000000..fa881b8374 --- /dev/null +++ b/backend/tests/live/README.md @@ -0,0 +1,495 @@ +# Durable live capture proof + +`canonical_capture.py` is opt-in and makes read-only Gmail/Drive/Calendar provider requests. +It commits real captured records and blobs into disposable local storage, then +checks reads over fresh database connections, paginated lists/change feeds, exact +payload equality, blob SHA256/size, and an identical replay with zero new changes. +It does **not** assert a completed account sync or save an upstream checkpoint. + +Requirements: + +- A separately initialized PostgreSQL database `sync_tests`, user `sync_test`, + reachable only through an explicitly supplied private Unix-socket directory. +- `CANONICAL_TEST_DATABASE_URL` using `postgresql+asyncpg`, that database/user, + no network hostname, and `?host=/absolute/private/socket&port=...`. +- `COMPOSIO_API_KEY`, `LIVE_GMAIL_ACCOUNT_ID`, `LIVE_DRIVE_ACCOUNT_ID`, and + `LIVE_EXPECTED_EMAIL` in the subprocess environment. The two accounts must + already be connected. The harness checks their actual provider email identity. + +Optionally set `LIVE_PROVIDERS=gmail` or `LIVE_PROVIDERS=google_drive` to verify +only one provider; default is both. `LIVE_PROVIDERS=google_calendar` selects Calendar +and requires `LIVE_CALENDAR_ACCOUNT_ID`. It verifies the primary CalendarList ID +against `LIVE_EXPECTED_EMAIL`. Only selected provider account variables are required. + +Run from `backend`, using your approved secret injector: + +```sh +PYTHONPATH=. .venv/bin/python tests/live/canonical_capture.py +``` + +The source sample stops after at least three records and one blob, or 25 records. +Gmail uses `newer_than:7d smaller:5M`; capture/download limits are reduced to 10 MiB +for this probe. A Gmail/Drive sample without a blob fails. Calendar samples up to +25 original records and requires an event; its JSON does not require blobs, so +blob verification is null when none exist. Listing includes tombstones so sparse +cancellations are verified alongside ordinary originals. No scope-completion observations +are persisted. The application database hostname is overridden to an unusable +value; only the explicit test URL reaches a database engine. + +A random `canonical_live_*` schema receives the real migrations. Files live under +one temporary directory with mode `0700`, and the process uses umask `077`. +Schema and files are removed in `finally`, including on failure. Database WAL may +retain deleted pages until the disposable PostgreSQL cluster is destroyed; use a +private test cluster and remove it after the larger verification session. Do not +run this against a production database or retain/export provider payloads. + +Output contains only counts, booleans, safe exception class names, stages, HTTP +status/host and allowlisted provider error codes. The live +script never prints credentials, record contents, account identifiers, filenames, +or SQL parameters. Automated mock/store tests remain separate from this proof. + +## Almanac HTTP consumer handoff + +For Gmail, optionally set `LIVE_ALMANAC_ROOT` to the Almanac checkout and +`LIVE_ALMANAC_PYTHON` to its Python 3.12 **venv executable path** (do not resolve +its symlink to the base interpreter). Run the same `canonical_capture.py` command +in Airweave's Python 3.13 environment. The callback serves the committed sample +through the actual `api_router` on an ephemeral loopback port, then invokes +Almanac's `backend/tests/live/read_owned_gmail.py`. + +The child receives a mode0600 manifest inside the existing mode0700 directory; +its environment contains no provider credentials. It reads an actual thread and +record/blob endpoints using `OwnedMailThreadReader` and `OwnedSourceRecords`, +compares native header/body/reference and payload digests, verifies blob SHA/size, +and checks that an unauthenticated probe request is denied. The server uses a +random-key-guarded **test context override**, not hosted WorkOS or real persisted +API-key authentication. It creates no production Almanac account binding. The +child and server are stopped before the enclosing harness removes schema/files. + +Verified 2026-09-30: 7 Gmail originals, 7 journal changes, 2 blobs totaling +1,383,758 bytes, exact replay with 0 new changes and fresh-connection readback. +The real HTTP → Almanac reader returned 1 message with complete body and verified +native fields; both blobs passed HTTP SHA/size verification. That selected body +was inline (`external_body_blobs=0`): external-body retrieval is covered by +synthetic tests, not claimed as live-covered by this sample. After the initial +venv-launch failure and successful rerun, independent checks found 0 retained +`canonical_live_*` schemas and 0 private live directories. No complete-source +coverage, checkpoint, hosted authentication, or deployed product proof is claimed. + +## Historical Slack and Wispr samples + +Both samples below used the former observation adapters. Slack and Wispr now +use durable pages; `canonical_capture.py` rejects these sources and directs callers +to the shared `provider_lifecycle.py`. Do not use `LIVE_PROVIDERS=wispr` or +`LIVE_PROVIDERS=slack` with the old sampler. + +The current lifecycle selects `LIVE_LIFECYCLE_PROVIDER=wispr`, with +`LIVE_WISPR_ACCOUNT_ID` and `LIVE_WISPR_USER_ID` for the existing connected account. +It verifies exact ACTIVE Composio account ID, toolkit and principal. This is weaker +than a provider mailbox/profile check and is not an Almanac user binding. Wispr +requires no email variable. Slack verifies `auth.test` and matching `users.info` +email against `LIVE_EXPECTED_EMAIL`. + +The historical samples stopped after three distinct records or25 observations. +Exact native JSON survived durable readback and identical replay added no journal +changes. Source markers/checkpoints remained deliberately uncommitted. These are +historical sample results, not verification of the new page adapters. + +Verified 2026-09-30: Slack produced four observations and three distinct records +(channel/message), with three journal changes, exact fresh-connection readback +and zero replay changes. Its provider email matched. No file-bearing message was +in that sample, so attachment bytes/partial file coverage were not live-tested. +Wispr produced three meeting records and three journal changes; all three remain +explicitly partial because the provider omits raw editor data/deletion history. +Its replay added zero changes. Neither sample contained blobs or proved complete +source coverage. Both private schema and files were removed afterward. + +## Current Gmail page lifecycle and bounded recovery + +The current Gmail source uses the production durable page/checkpoint protocol. +The existing `provider_lifecycle.py` remains the runner; no live execution of this +new harness is claimed here. The earlier lifecycle results below describe the +previous observation adapter. + +With `LIVE_LIFECYCLE_PROVIDER=gmail`, the default still freezes one seven-day +query and runs two complete configured-scope passes in fresh processes. Validation +now reads `canonical_cycle` and the fenced checkpoint; legacy marker counts and +`history_id` are not evidence for the page source. Payload digest differences are +reported honestly rather than requiring a stationary mailbox. + +For an explicitly selected unfiltered **partial recovery** probe, additionally set +`LIVE_GMAIL_UNFILTERED=1 LIVE_GMAIL_RESUME=1`. This clears the source's default +label/category filters rather than treating an empty custom query as unfiltered. +The first child exits with code75 only after SQL proves a message page committed. +A second child uses the same running job and a new attempt, resumes the identical +cycle/sweep/version, and exits with code76 after another acknowledged page. +Parent SQL readback independently verifies advancement. No payloads or provider +cursors enter printed evidence. A mailbox completing before interruption reports +`recovery_not_exercised`; it is not successful recovery proof. + +The existing600 provider-request,250 observation,600-second and256MiB blob-write +budgets are shared across the interruption/resume pair; each MIME blob remains +limited to10MiB. Exact known-message refreshes consume request, observation and +storage budgets too. Normal identity verification still reads Gmail's profile in +each process. Separate counters require **zero capture-boundary profile reads** +in the resumed process; identity verification is not counted as a boundary reset. + +`LIVE_GMAIL_UNFILTERED=1` without the resume flag selects two bounded full/changes +processes. A changes claim requires the first baseline actually to finish, the +next process to load its promoted checkpoint and issue history requests using that +exact boundary, and the second checkpoint to commit. Budget exhaustion remains +incomplete; no sampled records or interrupted baseline establish mailbox completion. +This option does not increase any existing download or process limit. + +## Historical configured Gmail reconciliation lifecycle + +`PYTHONPATH=. .venv/bin/python tests/live/provider_lifecycle.py` uses the same private +PostgreSQL, Composio key, `LIVE_GMAIL_ACCOUNT_ID` and `LIVE_EXPECTED_EMAIL` inputs. +It freezes a seven-day `after: before:` query before either run, +with no size or category filter. Each run is a full reconciliation of this query; +this does **not** verify Gmail history-delta resume or the whole mailbox. + +Two fresh Python processes run the production `SyncOrchestrator.run`, source, +canonical pipeline, job state machine/repository and cursor read/write path. +The harness injects private database sessions and disables external telemetry; +billing, non-applicable ACL and sync-pausing collaborators are test doubles. +It does not exercise the hosted factory, Temporal worker, WorkOS, or a product +account binding. The second process loads the persisted cursor; the successful +checkpoint must identify its committed attempt. Native JSON/revision digests +compare the two reads. A real mailbox change may make the second digest differ. + +Per-run limits: 250 message observations, 600 provider requests, 10 MiB per MIME +blob, 256 MiB total blob writes, and ten minutes. The record/request/storage +limits raise instead of truncating enumeration; no completed scope or checkpoint +is claimed on an aborted enumeration. Oversized MIME parts retain the source's explicit +partial-content status. The parent reaps the child, removes its schema and private +files on success or failure, and prints only counts, status and digests. + +Verified 2026-09-30: both fresh-process seven-day runs completed, each with 239 +message observations, 287 provider requests, 239 stored records, zero partial +records, and 5,501,801 MIME blob bytes written. Each emitted one start and one +completion marker and saved a checkpoint; the second loaded the durable cursor. +Its final change sequence was 270, so the two raw-payload/revision digests were +**not identical**. This is not a zero-change second-live-run claim. A separate +bounded diagnostic repeated one attachment-bearing message GET and observed only +`payload.parts[*].body.attachmentId` differences. Gmail can rotate these original +attachment locators; the canonical store preserves them. That diagnostic does +not retrospectively attribute all 31 earlier changes. Duplicate replay of an +identical captured observation remains independently tested. + +Both private schema/file cleanup checks returned zero leftovers. Fifteen Gmail +source tests and four real-PostgreSQL pipeline tests passed; failure/recovery +fixtures are simulated provider evidence, not live induced failures. The existing +Gmail source now emits an explicit `StartedScope` before full enumeration, +including filtered reconciliation and expired-history bootstrap. + +Calendar verification via `LIVE_LIFECYCLE_PROVIDER=google_calendar`: one verified +primary calendar with a fixed seven-day occurrence window completed both fresh +processes. Initial run: 4 provider requests, 190 observations, 183 active records, +3 completed scopes. Second run: 4 requests, 22 observations, 2 completed scopes, +and one actual event listing with the saved `syncToken`. Both jobs completed and +saved checkpoints; second-run change sequence stayed 190 and the native +payload/revision digest was identical. Zero partial records or blobs. Private +schema/files were removed. No all-day or DST case was observed; those semantics +remain covered separately by synthetic tests. Calendar limits are 100 provider +requests, 10,000 observations and 180 seconds per process. + +### Calendar consumer over real HTTP + +Set `LIVE_VERIFY_CALENDAR_CONSUMER=1` with the explicit `LIVE_ALMANAC_ROOT` and +`LIVE_ALMANAC_PYTHON` paths when running the selected Calendar lifecycle. The +second process uses the shared `almanac_handoff.run_consumer` loopback server and +the real `/sync` routers. A fresh Almanac process receives a private0600 manifest +and an ephemeral service key; provider credentials are excluded from its environment. +The temporary source connection exists only in the disposable PostgreSQL schema. +This does not verify hosted authentication, production bindings or Temporal workers. + +Verified September30:21 actual stored expanded events crossed this boundary in5 +pages (limit5), retaining real occurrence/calendar/account identities, title, +description, scheduling times, status, recurring master and coverage metadata. +Wrong-source reads were denied and an uncaptured2001 range returned the explicit +product error. Both capture jobs completed with4 provider requests each,190 initial +observations and22 second-run observations,183 active records, unchanged journal +sequence190 and unchanged native payload/revision digest. The second fresh process +loaded its durable checkpoint and made one actual master `syncToken` request. +No all-day events occurred in this sample; DST/all-day correctness remains fixture +coverage. The private schema and files were removed; an independent PostgreSQL +check found zero remaining `canonical_live_*` schemas. No provider writes occurred. + +Drive mode: `LIVE_LIFECYCLE_PROVIDER=google_drive` with `LIVE_DRIVE_ACCOUNT_ID` +uses the source's entire accessible `allDrives` corpus; no query, path selection, +or hidden sampling filter is inserted. The initial verification budget was +2,000 observations, 250 provider requests, 128 MiB total retained blob writes, +10 MiB per blob and 300 seconds. The approved follow-up budget uses the source +production limit of 200 MiB per blob and 512 MiB total; record/request/time +limits stay unchanged. Drive checks for at least 2 GiB free disk before each +process starts. A successful initial run must exhaust enumeration plus changes +replay and commit its canonical page token. A second fresh process must actually +request `/changes` with that saved token and emit no full-rescan markers; an +expired token/reset is not reported as successful incremental proof. Failure +stops the attempt and reports whether the previous checkpoint stayed unchanged. + +Drive attempt 2026-09-30: the full accessible scope stopped at the configured +10 MiB per-file transport ceiling (`Proxy download exceeds the file size limit`) +during a binary download. The initial attempt and one sanitized diagnostic retry +each made 11 provider requests and yielded 4 observations, with one start marker +and **no** completed scope. The real job became failed; the checkpoint remained +unchanged. No second incremental process was started. This proves the bounded +abort path, not complete Drive synchronization. Both attempts removed their +private schema/files. Thirteen Drive source/content tests passed; a synthetic +storage-budget check also rejected an over-budget write before retaining it. + +The approved production-size Drive retry completed on 2026-09-30. Initial run: +85 provider requests, 55 observed/stored records, three not content-complete, +207,597,309 retained blob bytes, one completed full scope and a saved checkpoint. +A fresh second process made two requests, including `/changes` with the actual +saved page token, observed zero changes and saved its new attempt checkpoint. +Change sequence stayed 55 and payload/revision digests were identical. Both jobs +completed. Free disk before the run was 9,525,235,712 bytes; private schema/files +were removed and independently checked absent. This verifies the current +accessible Drive scope and a quiet real delta run; it does not prove the three +incomplete bodies, a hosted consumer, or live induced deletions/revocations. +The earlier smaller-budget failure evidence remains unchanged. Sanitized result: +`evidence/drive-lifecycle-production-limit-20260930.json`. + + +### Slack and Wispr full lifecycle attempts + +These historical attempts used the real canonical orchestrator and production +`cursor=None`. Current adapters use canonical durable page checkpoints. Slack's +interruption trial is described below. Wispr now resumes individual meeting scopes, +but has no provider delta cursor or deletion evidence; its discovery-only traversal +is not an exhaustive-source claim. Its current lifecycle runs first/second cycles; +the deliberate same-job interruption probe is still Slack-specific. +Actual network requests are metered, including Wispr session/account operations. + +The first Slack full-accessible-history attempt stopped on a rate limit after +70 requests and 457 observations, with two started scopes and zero completed. +The job failed, checkpoint stayed unchanged, and private schema/files were removed. +No repeat process started. Retry-After was not retained in that first attempt. +The subsequent approved budget is 600 requests, 10,000 observations and 1,800 +seconds per process. Slack uses its five-attempt retry policy and honors the +full Retry-After wait; the test does not shorten waits to fit its total budget. +Only status, numeric retry delay and request count are retained as diagnostics. +The longer attempt reached its 1,800-second deadline after 74 requests and 328 +observations. Thirty rate limits each supplied a 60-second Retry-After. The job +became cancelled; two scopes started, none completed, and the checkpoint stayed +unchanged. Cancellation telemetry then accessed missing fixture connection +metadata, masking the timeout with AttributeError. The fixture is corrected; +this was not rerun. No second process started, and private schema/files were +removed. Full Slack coverage remains unverified. The durable per-scope recovery +implementation below addresses this retry limitation; it has only synthetic +HTTP/PostgreSQL and local cross-process verification so far. +See `evidence/slack-lifecycle-backoff-incomplete-20260930.json`. + +Wispr enumeration and one diagnostic retry each failed after 18 requests and nine +partial observations. `GET_MEETING` returned an opaque tool error string; the +source stopped instead of skipping that meeting. A targeted read-only diagnostic +found no structured status/code or recognized auth/not-found/rate-limit category. +The underlying provider versus intermediary cause is unknown. No complete source, +checkpoint, repeat process or deletion guarantee is claimed. Both lifecycle +attempts cleaned private storage. This is additional failure evidence alongside +the earlier successful three-record sample, not a replacement for that sample. +See `evidence/slack-lifecycle-incomplete-20260930.json` and +`evidence/wispr-lifecycle-incomplete-20260930.json`. + + +### Current Slack cross-process recovery trial (not yet run live) + +`LIVE_LIFECYCLE_PROVIDER=slack` uses the production page adapter, capture pipeline, +SQL state, and job state machine. It has three stages within **one aggregate cap**: +600 provider requests, 10,000 observed records, and 7,200 seconds including all +Retry-After waits and subprocess startup. + +1. Start a new job. After a history page commits pending thread IDs, the test-only + wrapper verifies the exact SQL continuation, active cycle, current attempt and + running job. It writes private UUID/version evidence, flushes sanitized counters, + and exits 75 before another provider request. This simulates process loss without + marking the job failed or cancelled. +2. Start a fresh process using the same job and attempt 2. The production driver + refreshes membership, then resumes the saved child state. A rejected cursor is + reported separately from successful cursor reuse; the normal one-restart bound + still applies. No complete-scope claim is made until actual finalization. +3. Only after completion, start a genuinely new job and verify its new cycle. + +The parent subtracts each child's counters from the remaining limits and enforces +one wall-clock deadline even while a child sleeps. No pending thread found, changed +scope, or exhausted budget leaves the requested resume proof incomplete. A forcibly +killed child's counters are unknown: aggregate exact counts are null and known +counts are reported only as lower bounds. No further stage follows that failure. +After the resumed child exits (including a forced timeout), the parent reads the +saved scan directly from SQL. Same tenant, sync, job, attempt 2, cycle and sweep, +with an advanced revision, proves a resumed page committed. This is reported as +`resumed_page_committed`; `resumed_partial` means that proof exists but the cycle +has not completed. Scope withdrawal and expired-cursor restarts cannot satisfy +this proof. Full-cycle and subsequent-new-cycle completion remain separate checks. +Private schema, downloaded files, and resume evidence are removed by the parent. + +`test_resume_probe.py` verifies the actual special process exit and fresh-process +resume using synthetic Slack responses and isolated PostgreSQL. Its budget tests +use fake child processes to check decreasing limits and unknown killed-child usage. +These are harness tests, not evidence of live Slack coverage or throughput. + +### Opt-in Wispr same-job recovery proof + +From `backend`, with the same private PostgreSQL and existing verified account environment: + +```sh +LIVE_LIFECYCLE_PROVIDER=wispr LIVE_WISPR_RESUME=1 PYTHONPATH=. .venv/bin/python tests/live/provider_lifecycle.py +``` + +Required secret environment names are `COMPOSIO_API_KEY`, `LIVE_WISPR_ACCOUNT_ID` +and `LIVE_WISPR_USER_ID`; PostgreSQL uses `CANONICAL_TEST_DATABASE_URL`. Do not +print these values. This mode shares a hard20 HTTP request,600 observed record, +180 second budget across both processes, including account/session/listing calls. +Set `LIVE_WISPR_REQUEST_LIMIT=17` to reduce the aggregate request budget; values +from1 through20 are validated before setup/network. The default remains20. +No retries or new account grants occur. Errors, including rate failures, stop the trial. + +The first process exits before another body call after SQL verifies three completed +meeting children. The second uses the same job/cycle with a new writer attempt, +refreshes listing membership and commits one additional body. Before each body call, +the typed probe rejects a completed sibling identity. It verifies prior scan revisions +remain unchanged and then exits intentionally. Private evidence contains only scan +identities and native-ID SHA256 digests, never meeting content; output contains counts +and booleans. Parent cleanup removes the private schema/files on all normal or failed +exits. `wispr_recovery_verified` is distinct from full traversal or deletion completeness. + +A source with fewer than five discoverable meetings, many body ranges, changed inventory, +rate failure or exhausted budget can leave the proof inconclusive. Without the opt-in +flag, Wispr retains its ordinary first/second-cycle trial; that is not a recovery proof. +Offline PostgreSQL/subprocess tests verify the actual process exits and fresh retry, +failed-body stop, and aggregate budget. The subsequent live result is recorded below. + +Live checkpoint, 2026-09-30 (`cf14b4e`): the pipeline constructor had discarded +`completion_policies` while checking topology; the fix preserves the source policy +and still rejects incorrect topology. Full orchestrator/subprocess tests now cover +this integration, rather than only the scan driver. + +The subsequent14-request trial used12 requests for253 listing observations and +three committed meeting bodies, then intentionally exited75. The fresh process +used its remaining2 requests during account/session setup (last response HTTP201) +and hit the request guard before validation/body capture. Thus storage and intentional +interruption are live-verified; recovery remains **unverified**. Together with the +six prior preflight requests, this exhausted the20-request batch. Private schemas, +files and code archive were removed; independent schema count was zero. See +[evidence](evidence/wispr-recovery-budget-incomplete-20260930.json). + +A future independently approved trial with a fresh20-request budget is plausibly +sufficient, not guaranteed: measured first-stage12 + expected fresh setup3 + listing +refresh2 leaves3 GET/range calls for one additional meeting. A one-call body needs18 +requests total; a body requiring4 calls needs21 and must stop under the current cap. +Inventory and range sizes can change, and prior rate failures provide no reliable +reset interval. Keep the current20 maximum and180-second deadline; do not automatically +raise limits or rerun after a failure. No further live requests were made for this note. + + +**Latest live result, 2026-09-30: recovery verified.** A separate fresh20-request, +180-second trial59141 ran immutable `2318901`. First stage:10 HTTP requests, +256 observations, three completed meeting bodies, intentional exit75. Fresh +same-job process:8 HTTP requests,254 observations, one additional body committed, +intentional exit76 with `wispr_recovery_verified=true`. Aggregate:18 requests and +510 observations. These observations include repeated listing inventory, not510 +unique meetings. The prior three body scopes retained the same cycle/revisions; +the probe rejected any attempt to fetch their identities again. + +This proves per-meeting recovery through the actual source/orchestrator/store, +not a full-source traversal, new-cycle refresh, exhaustive discovery, deletion +handling or Almanac owner attestation. A failed meeting may repeat its own ranges; +all bodies remain partial because provider raw editor data is unavailable. +Parent independently verified zero live schemas and private-live directories and +removed the immutable code archive. The earlier20-request failed batch remains +historical evidence; this successful trial had its own20-request cap. Safe results: +[evidence](evidence/wispr-recovery-20260930.json), committed `78e7f37`. + +## Retained browser evaluation (explicit opt-in) + +`evaluation.py capture --directory /absolute/new/private/directory` retains a +bounded Gmail + Drive sample for the local UI. It reuses `provider_lifecycle.child` and its production durable-page driver, +the production projector/converters, real local MiniLM/BM25 embeddings, and Vespa. +The default `canonical_capture.py` still always removes its schema/files. This +separate evaluation command requires the same private `sync_tests` Unix socket, +provider environment, and additionally `LIVE_DRIVE_PERMISSION_ID`. Each provider +trial is bounded to 180 requests, 500 records, 180 seconds and 50 MiB of blob writes; +one file is bounded to 10 MiB. Gmail uses `newer_than:1d smaller:100K`. Committed +pages remain searchable if a later page hits a bound; interrupted capture is explicit +in the printed lifecycle evidence and must not be represented as a complete sync. + +Run from `backend` with `PYTHONPATH=.`. The named directory must not already exist; +it is created mode0700 with a mode0600 `manifest.json`. This contains service keys +and source identities and must never be committed or copied into public logs. +Failed evaluations also retain their explicitly requested directory/schema for +inspection and cleanup. API keys expire after one day. Native identity verification +happens before retained binding metadata is created. + +Local dependencies are isolated Vespa on `127.0.0.1:8081/19071`, the repository's +MiniLM inference image on `127.0.0.1:9878`, and Redis on `127.0.0.1:16379`. Deploy +`vespa/app` with dimension384 before capture. Model inference is real; no model API +key or paid model call is necessary. PDF text extraction uses the existing local +extractor; scanned PDFs needing OCR are not claimed covered without an OCR service. + +`evaluation.py serve --directory SAME_DIRECTORY` serves the actual Airweave API +router on `127.0.0.1:18081`, with its production container and persisted encrypted +organization API keys. It does not override authentication. Only DB session ownership +is redirected to the explicit isolated schema. No Temporal schedules or worker are +started. This is a bounded capture/search/read evaluation, **not full synchronization +or automatic Connect qualification**. Almanac must use its own disposable database, +explicit development actor, real source bindings and existing hosted HTTP proxy. +Development-token authentication is not proof of WorkOS login. + +Cleanup after stopping the API: delete each evaluation sync's Vespa documents +through `VespaDestination.delete_by_sync_id`, drop only the manifest's validated +`canonical_eval_<32 hex>` schema from the explicitly private `sync_tests` database, +and remove the exact private evaluation directory and provider-environment file. +The disposable evaluation containers can then be removed by their exact names; +never run global Docker pruning or alter the product database. PostgreSQL WAL and +Docker filesystem blocks may retain deleted bytes until those disposable resources +are destroyed. The private corpus remains on this machine until that cleanup. + + +Verified local evaluation, 2026-09-30 (before subsequent extraction repairs): + +- Real MiniLM 384-dimensional and BM25 inference passed; Vespa schema was deployed. +- Actual API-key middleware denied absent keys (401), invalid keys (403), and + admitted the persisted organization keys (200). No context override was used. +- Gmail: 50 originals from the explicit one-day/100 KiB filter, 47 published text + representations, 6 original blobs verified by HTTP SHA256. Three projections + remain pending: two native MIME size discrepancies and one image-conversion failure. +- Drive: 4 committed originals before a file exceeded the 10 MiB trial limit; + 3 published representations, 5 original blobs verified by HTTP SHA256. Content + includes native Google Doc data and DOCX/PPTX/XLSX; no PDF was present in this sample. +- Exact record reads and keyword search returned actual retained records for both + providers. This does not establish full-account coverage, scheduling recovery, + WorkOS login, cloud deployment, or overall search quality. + +Use `evaluation.py project --directory SAME_DIRECTORY` to process pending stored +records without provider access. It traverses all pending pages once; failures stay +pending and are reported, rather than being silently marked indexed. The service +reads current SQL publication state, so reprojection becomes visible without an API +restart. + + +`evaluation.py extend --directory SAME_DIRECTORY --provider google_calendar|slack` +adds one native-attested source while preserving existing keys and source identities. +Extensions run sequentially because the private manifest has one owner. Calendar +uses its verified primary ID and a seven-day occurrence materialization window; +original masters are also retained by the production connector. Slack uses the +verified workspace/user pair, with `external_user_id` retained for the Almanac binding. +Source construction starts at the current canonical search metadata pipeline version; +older retained trials upgrade using `plan_reprojection` followed by `project`. + +The Calendar trial retained 183 visible originals (190 observations including removals) +and projected every record. The Slack trial stopped at its record budget after 56 +requests: 460 committed originals were all projected, with capture still explicitly +active/incomplete. Wispr's separate private broker-attested trial stopped on an upstream +tool error with a rate signal after 18 requests/284 observations; no native account +identity or Almanac binding is claimed for it. + +Drive's second bounded trial resumed the same unfinished full-scan cursor with a +200 MiB file/512 MiB total blob limit after checking at least 10 GiB free disk. It +retained 14 PDFs among the captured originals. The source job completed and SQL showed +a complete full cycle with a promoted checkpoint. The former live-helper assertion +incorrectly assumed any loaded cursor implies a changes pass; it now distinguishes +unfinished full-scan continuation from subsequent/resumed changes while still requiring +checkpoint promotion. This was a harness assertion failure, separately recorded from +the actual completed source job. PDF text/read verification remains a separate check. diff --git a/backend/tests/live/almanac_handoff.py b/backend/tests/live/almanac_handoff.py new file mode 100644 index 0000000000..0a590661c2 --- /dev/null +++ b/backend/tests/live/almanac_handoff.py @@ -0,0 +1,281 @@ +"""Optional cross-environment proof through the real owned-service HTTP router.""" + +import asyncio +import base64 +import hashlib +import json +import os +import secrets +import socket +from email.header import decode_header, make_header +from email.message import Message +from email.utils import getaddresses +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import uvicorn +from fastapi import FastAPI, HTTPException, Request + +from airweave.api import deps +from airweave.api.v1.api import api_router +from airweave.api.v1.endpoints.records import record_error_response +from airweave.db.session import get_db +from airweave.domains.entities.canonical.store import CanonicalStoreError + + +def digest(value): + return hashlib.sha256(value.encode()).hexdigest() + + +async def expectation(record, storage): + """Fingerprint native data independently before crossing the HTTP/process boundary.""" + payload = record.payload + headers = {h["name"].lower(): h["value"] for h in payload["payload"].get("headers", [])} + bodies = {"text/plain": [], "text/html": []} + body_blobs = 0 + + async def walk(part, path): + nonlocal body_blobs + metadata = Message() + for header in part.get("headers", []): + metadata[header["name"]] = header["value"] + if part.get("filename") or metadata.get_content_disposition() == "attachment": + return + if part.get("mimeType") in bodies: + body = part.get("body", {}) + if "data" in body: + value = body["data"] + raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + else: + blob = next((b for b in record.blobs if b.source_path == path + "/body"), None) + raw = await storage.read_file(blob.key, max_bytes=blob.size_bytes) if blob else b"" + body_blobs += int(blob is not None) + bodies[part["mimeType"]].append(raw.decode(metadata.get_content_charset() or "utf-8")) + for index, child in enumerate(part.get("parts", [])): + await walk(child, f"{path}/parts/{index}") + + await walk(payload["payload"], "/payload") + return { + "record_id": str(record.id), + "native_id": record.identity.native_id, + "revision": record.revision, + "observed_at": record.observed_at.isoformat(), + "payload_digest": digest(json.dumps(payload, sort_keys=True, separators=(",", ":"))), + "subject_digest": digest(str(make_header(decode_header(headers.get("subject", ""))))), + "sender_digest": digest( + json.dumps( + [ + [str(make_header(decode_header(name))), address] + for name, address in getaddresses([headers.get("from", "")]) + if address + ] + ) + ), + # Alternative bodies select one representation; mixed bodies may join leaves. + # Exact MIME semantics are separately tested; here every rendered byte must + # correspond to the native captured representation across process/HTTP boundaries. + "text_digests": [ + digest(x) for x in [*bodies["text/plain"], "\n".join(bodies["text/plain"])] + ], + "html_digests": [digest(x) for x in [*bodies["text/html"], "\n".join(bodies["text/html"])]], + "body_blobs": body_blobs, + } + + +async def verify_almanac_reader( + *, records, storage, query, sessions, organization_id, sync_id, root +): + python = Path(os.environ["LIVE_ALMANAC_PYTHON"]).absolute() + repository = Path(os.environ["LIVE_ALMANAC_ROOT"]).resolve() + consumer = repository / "backend/tests/live/read_owned_gmail.py" + if not python.is_file() or not consumer.is_file(): + raise ValueError("Explicit Almanac Python and consumer checkout are required") + candidates = [r for r in records if r.blobs and r.completeness == "complete"] + if not candidates: + raise ValueError("A complete native message with a stored blob is required") + thread_id = candidates[0].payload["threadId"] + selected = [r for r in records if r.payload.get("threadId") == thread_id] + expected = [await expectation(record, storage) for record in selected] + return await run_consumer( + sessions=sessions, + organization_id=organization_id, + sync_id=sync_id, + root=root, + storage=storage, + query=query, + consumer=consumer, + repository=repository, + python=python, + extra={"thread_id": thread_id, "expected": expected}, + ) + + +async def run_consumer( + *, sessions, organization_id, sync_id, root, storage, query, consumer, repository, python, extra +): + """Shared private loopback API/process boundary for provider-specific assertions.""" + probe_key = secrets.token_urlsafe(32) + app = FastAPI() + app.include_router(api_router) + app.add_exception_handler(CanonicalStoreError, record_error_response) + + async def context(request: Request): + if not secrets.compare_digest(request.headers.get("x-api-key", ""), probe_key): + raise HTTPException(status_code=401) + return SimpleNamespace(organization=SimpleNamespace(id=organization_id)) + + async def database(): + async with sessions() as db: + yield db + + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[get_db] = database + app.dependency_overrides[deps.get_canonical_query_service] = lambda: query + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace(storage_backend=storage) + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(128) + listener.setblocking(False) + server = uvicorn.Server(uvicorn.Config(app, log_config=None, access_log=False, lifespan="off")) + task = asyncio.create_task(server.serve(sockets=[listener])) + process = None + manifest = root / "almanac-handoff.json" + try: + async with asyncio.timeout(20): + while not server.started: + if task.done(): + await task + raise RuntimeError("Local API server did not start") + await asyncio.sleep(0.01) + manifest.write_text( + json.dumps( + { + "origin": f"http://127.0.0.1:{listener.getsockname()[1]}", + "api_key": probe_key, + "organization_id": str(organization_id), + "sync_id": str(sync_id), + "account_id": str(uuid4()), + "source_connection_id": str(uuid4()), + **extra, + } + ) + ) + manifest.chmod(0o600) + # Provider keys/identities do not enter the consumer subprocess environment. + environment = { + "PATH": os.environ.get("PATH", ""), + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": str(repository / "backend") + + os.pathsep + + str(repository / "shared/revtext"), + } + process = await asyncio.create_subprocess_exec( + str(python), + str(consumer), + str(manifest), + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + async with asyncio.timeout(120): + output, _ = await process.communicate() + return consumer_result(output, process.returncode) + finally: + try: + await stop_probe(process, server, task) + finally: + listener.close() + manifest.unlink(missing_ok=True) + + +async def stop_probe(process, server, task): + """Reap child/server before the caller destroys their private storage.""" + if process is not None and process.returncode is None: + process.kill() + await process.wait() + server.should_exit = True + try: + async with asyncio.timeout(10): + await task + except TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +def consumer_result(output, returncode): + """Accept only the consumer's structured result; suppress private diagnostics.""" + if not output.strip(): + raise RuntimeError("Consumer interpreter failed before structured verification") + result = json.loads(output) + if returncode or not result.get("verified"): + safe_stage = result.get("stage") + if safe_stage in { + "handoff", + "thread_read", + "exact_record_read", + "blob_read", + "context_negative_check", + }: + print(json.dumps({"consumer_failed_stage": safe_stage}), flush=True) + raise RuntimeError("Almanac consumer proof failed; private diagnostics suppressed") + return result + + +async def verify_calendar_reader( + *, sessions, organization_id, sync_id, root, storage, window, calendar_id +): + """Hand actual committed occurrences to the Almanac reader through the same HTTP server.""" + from airweave.domains.entities.canonical.calendar_query import ( + CalendarRange, + CalendarRangeService, + ) + from airweave.domains.entities.canonical.query import CanonicalQueryService + from airweave.domains.entities.canonical.query_store import CanonicalQueryStore + from airweave.domains.entities.canonical.store import CanonicalRecordStore + from airweave.models.source_connection import SourceConnection + + query = CanonicalQueryService(CanonicalRecordStore(), CanonicalQueryStore(), "private-test") + request = CalendarRange(start=window["start"], end=window["end"], timezone="UTC", limit=250) + async with sessions() as db: + page = await CalendarRangeService("private-test").read( + db, organization_id, sync_id, calendar_id, request + ) + if page.has_more: + raise ValueError("Consumer proof exceeds bounded expected-page limit") + connection_id = uuid4() + async with sessions() as db: + db.add( + SourceConnection( + id=connection_id, + organization_id=organization_id, + sync_id=sync_id, + name="Private calendar consumer", + short_name="google_calendar", + is_authenticated=True, + ) + ) + await db.commit() + repository = Path(os.environ["LIVE_ALMANAC_ROOT"]).resolve() + python = Path(os.environ["LIVE_ALMANAC_PYTHON"]).absolute() + consumer = repository / "backend/tests/live/read_owned_calendar.py" + if not python.is_file() or not consumer.is_file(): + raise ValueError("Explicit Almanac consumer checkout and Python required") + return await run_consumer( + sessions=sessions, + organization_id=organization_id, + sync_id=sync_id, + root=root, + storage=storage, + query=query, + consumer=consumer, + repository=repository, + python=python, + extra={ + "source_connection_id": str(connection_id), + "calendar_id": calendar_id, + "window": window, + "expected": [record.payload for record in page.records], + "coverage": page.coverage.model_dump(mode="json"), + }, + ) diff --git a/backend/tests/live/calendar_lifecycle.py b/backend/tests/live/calendar_lifecycle.py new file mode 100644 index 0000000000..ab8afe823b --- /dev/null +++ b/backend/tests/live/calendar_lifecycle.py @@ -0,0 +1,80 @@ +"""Private SQL assertions for the selected-calendar lifecycle; no token output.""" + +from datetime import datetime +from urllib.parse import unquote + +from sqlalchemy import select + +from airweave.domains.entities.canonical.coverage import capture_coverage +from airweave.domains.entities.canonical.scope_execution import ScopeExecution +from airweave.models.capture_scan import CaptureScan + + +async def event_checkpoint(db, organization_id, sync_id, calendar_id): + row = await db.scalar( + select(CaptureScan).where( + CaptureScan.organization_id == organization_id, + CaptureScan.sync_id == sync_id, + CaptureScan.record_type == "event", + CaptureScan.container_id == calendar_id, + ) + ) + if row is None or not row.execution_state: + return None + execution = ScopeExecution.model_validate(row.execution_state) + return execution.published.checkpoint if execution.published else None + + +def count_calendar_request(request, calendar_id, previous, counters): + if not request.url.params.get("syncToken"): + return + assert previous is not None + assert unquote(request.url.path).endswith(f"/calendars/{calendar_id}/events") + assert request.url.params["syncToken"] == previous.value["sync_token"] + counters["sync_token_requests"] += 1 + + +async def verify_calendar_scopes(sessions, organization_id, sync_id, manifest, saved, loaded): + """Require real completed scope publication and current SQL public coverage.""" + if manifest["provider"] != "google_calendar": + return + calendar_id = manifest["calendar_config"]["calendar_ids"][0] + async with sessions() as db: + rows = list( + await db.scalars( + select(CaptureScan).where( + CaptureScan.organization_id == organization_id, CaptureScan.sync_id == sync_id + ) + ) + ) + coverage = (await capture_coverage(db, organization_id, (sync_id,)))[sync_id] + assert len(rows) == 3 + assert {row.record_type for row in rows} == {"calendar", "event", "event_occurrence"} + cycle = saved["canonical_cycle"] + for row in rows: + assert row.phase == "complete" and str(row.cycle_id) == cycle["version"]["cycle_id"] + assert row.container_id == (None if row.record_type == "calendar" else calendar_id) + execution = ScopeExecution.model_validate(row.execution_state) + assert execution.last_full is not None and execution.published is not None + assert execution.published.sweep_id == row.sweep_id + if row.record_type == "event": + assert execution.plan.mode == ("changes" if loaded else "full") + assert execution.published.checkpoint.value["sync_token"] + if row.record_type == "event_occurrence": + params = execution.published.request_context["parameters"] + window = manifest["calendar_config"]["occurrence_window"] + + assert datetime.fromisoformat(params["timeMin"]) == datetime.fromisoformat( + window["start"] + ) + assert datetime.fromisoformat(params["timeMax"]) == datetime.fromisoformat( + window["end"] + ) + assert coverage.phase == "complete" and coverage.mode == "mixed" + assert coverage.last_full_capture is None and coverage.provider_checkpoint_promoted_at is None + assert coverage.scope_summary.model_dump() == { + "eligible": 3, + "completed_full": 2 if loaded else 3, + "completed_changes": 1 if loaded else 0, + "unfinished": 0, + } diff --git a/backend/tests/live/canonical_capture.py b/backend/tests/live/canonical_capture.py new file mode 100644 index 0000000000..ca5e1cc175 --- /dev/null +++ b/backend/tests/live/canonical_capture.py @@ -0,0 +1,383 @@ +"""Opt-in durable provider proof against an isolated, private local test PostgreSQL. + +Run from backend with PYTHONPATH=. and explicit environment variables documented in +README.md. No provider writes; this script writes real private records only into a +unique disposable schema and a mode0700 temporary blob directory. Both are removed. +""" + +import asyncio +import hashlib +import json +import logging +import os +import secrets +from contextlib import aclosing +from pathlib import Path +from tempfile import TemporaryDirectory +from uuid import uuid4 + +# The application DSN is deliberately made unusable. Only the explicit test DSN below +# is ever handed to an engine. Provider credentials remain subprocess environment data. +os.environ.update( + ENVIRONMENT="local", + TESTING="true", + AUTH_ENABLED="false", + AUTH_MODE="local", + POSTGRES_HOST="unused-live-probe.invalid", + POSTGRES_USER="unused", + POSTGRES_PASSWORD="unused", +) +import httpx # noqa: E402 +from pydantic import ValidationError # noqa: E402 +from sqlalchemy import text # noqa: E402 +from sqlalchemy.engine import make_url # noqa: E402 +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402 + +import conftest # noqa: E402,F401 - local test settings must precede Airweave imports +from airweave.adapters.storage.filesystem import FilesystemBackend # noqa: E402 +from airweave.domains.entities.canonical.query import CanonicalQueryService # noqa: E402 +from airweave.domains.entities.canonical.query_models import ( # noqa: E402 + RecordFilters, + RecordListQuery, +) +from airweave.domains.entities.canonical.query_store import CanonicalQueryStore # noqa: E402 +from airweave.domains.entities.canonical.requests import CaptureBatch, CaptureRecord # noqa: E402 +from airweave.domains.entities.canonical.service import CanonicalCaptureService # noqa: E402 +from airweave.domains.entities.canonical.store import CanonicalRecordStore # noqa: E402 +from airweave.domains.entities.canonical.tests.conftest import migrate # noqa: E402 +from airweave.domains.storage.file_service import FileService # noqa: E402 +from airweave.domains.storage.paths import StoragePaths # noqa: E402 +from airweave.models import Organization, Sync, SyncJob # noqa: E402 + +logging.disable(logging.CRITICAL) +PROBE_STAGE = "initialization" + + +def test_database_url() -> str: + """Reject network/application databases even if a caller supplies the wrong URL.""" + value = os.environ["CANONICAL_TEST_DATABASE_URL"] + url = make_url(value) + socket = url.query.get("host", "") + if ( + url.drivername != "postgresql+asyncpg" + or url.database != "sync_tests" + or url.username != "sync_test" + or url.host + or not socket.startswith("/") + ): + raise ValueError("An explicit private Unix-socket sync_tests database is required") + directory = Path(socket).resolve() + if ( + not directory.is_dir() + or directory.stat().st_uid != os.getuid() + or directory.stat().st_mode & 0o077 + ): + raise ValueError("The database socket directory must belong to the current user") + return value + + +async def fetch_records(name, account, expected_email, key, fence, root): + """Exercise actual source capture, bounded before any complete-scope claim.""" + from provider_sample import rest_source, wispr_source + + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + from airweave.domains.entities.canonical.requests import RecordIdentity + from airweave.domains.entities.canonical.source import ContainerScopedSource + + if name == "google_drive" and os.environ.get("LIVE_WORKSPACE_DOCUMENT") == "1": + from workspace_document import fetch_workspace_document + + return await fetch_workspace_document(account, expected_email, key, fence, root) + + storage = FilesystemBackend(root / "blobs") + files = FileService(fence.job_id, storage, sync_id=fence.sync_id) + files.MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 + connection = ( + wispr_source(account, key, fence) + if name == "wispr" + else rest_source(name, account, expected_email, key, fence) + ) + records = {} + observations = 0 + async with asyncio.timeout(180), connection as (source, identity_verification): + if isinstance(source, CanonicalPageSource): + raise ValueError("Page sources require the production provider_lifecycle.py harness") + parents = ( + source.canonical_container_parents if isinstance(source, ContainerScopedSource) else {} + ) + async with aclosing(source.generate_observations(files=files)) as stream: + async for item in stream: + # No scope-completion marker/checkpoint is saved by a bounded sample. + if not isinstance(item, CaptureRecord): + continue + observations += 1 + parent_type = parents.get(item.identity.record_type) + if parent_type is not None: + # Apply the declared source relationship just as the pipeline does; + # preserve the provider payload verbatim. + parent = RecordIdentity( + record_type=parent_type, native_id=item.identity.container_id + ) + if item.parent is not None and item.parent != parent: + raise ValueError("Source parent conflicts with its declared relationship") + item = item.model_copy(update={"parent": parent}) + identity = (item.identity.record_type, item.identity.entity_key) + records[identity] = item + if sample_complete(name, list(records.values()), observations): + break + values = list(records.values()) + required = {"google_calendar": "event", "slack": "message", "wispr": "meeting"}.get(name) + if required and not any(x.identity.record_type == required for x in values): + raise ValueError("Bounded source sample did not contain the required record type") + if name in {"gmail", "google_drive"} and not any(x.blobs for x in values): + raise ValueError("Bounded source sample did not contain a real owned blob") + return values, storage, identity_verification, observations + + +def sample_complete(name, records, observations): + """Bound provider work; a sample is not a completed enumeration.""" + if observations >= 25: + return True + if name == "google_calendar": + return False + if len(records) < 3: + return False + if name in {"gmail", "google_drive"}: + return any(record.blobs for record in records) + required = "message" if name == "slack" else "meeting" + return any(record.identity.record_type == required for record in records) + + +async def verify(name, account, email, key, sessions, engine, root): + """Commit, disconnect, reopen, read/list/page, verify disk bytes, then replay.""" + organization_id, sync_id, job_id = uuid4(), uuid4(), uuid4() + async with sessions() as db: + db.add(Organization(id=organization_id, name="Private live capture proof")) + await db.flush() + db.add(Sync(id=sync_id, organization_id=organization_id, name="Bounded live " + name)) + await db.flush() + db.add( + SyncJob(id=job_id, organization_id=organization_id, sync_id=sync_id, status="running") + ) + await db.commit() + store = CanonicalRecordStore() + service = CanonicalCaptureService(store) + query = CanonicalQueryService(store, CanonicalQueryStore(), secrets.token_urlsafe(32)) + async with sessions() as db: + fence = await service.activate_writer( + db, organization_id, sync_id, job_id, attempt_id=uuid4(), attempt_number=1 + ) + global PROBE_STAGE + PROBE_STAGE = name + ":provider_capture" + records, _, identity_verification, observations = await fetch_records( + name, account, email, key, fence, root + ) + PROBE_STAGE = name + ":durable_capture" + async with sessions() as db: + committed = await service.capture(db, CaptureBatch(fence=fence, records=tuple(records))) + await engine.dispose() # Force readback over fresh PostgreSQL connections. + PROBE_STAGE = name + ":durable_readback" + storage = FilesystemBackend(root / "blobs") # New filesystem storage instance. + expected = {(item.identity.record_type, item.identity.entity_key): item for item in records} + listed = [] + cursor = None + while True: + async with sessions() as db: + page = await query.list_records( + db, + organization_id, + sync_id, + RecordListQuery(limit=2, cursor=cursor, filters=RecordFilters(state="all")), + ) + listed.extend(page.records) + if not page.has_more: + break + cursor = page.next_cursor + assert len(listed) == len(expected) + total_bytes = blob_count = 0 + for stored in listed: + assert ( + stored.payload + == expected[(stored.identity.record_type, stored.identity.entity_key)].payload + ) + async with sessions() as db: + reread = await query.read(db, organization_id, sync_id, stored.id) + assert reread.payload == stored.payload and reread.revision == 1 + for blob in reread.blobs: + content = await storage.read_file(blob.key) + assert len(content) == blob.size_bytes + assert hashlib.sha256(content).hexdigest() == blob.sha256 + total_bytes += len(content) + blob_count += 1 + changes = [] + change_cursor = None + while True: + async with sessions() as db: + page = await query.changes(db, organization_id, sync_id, cursor=change_cursor, limit=2) + changes.extend(page.changes) + change_cursor = page.next_cursor + if not page.has_more: + break + assert len(changes) == len(expected) == len(committed.changes) + async with sessions() as db: + replay = await service.capture(db, CaptureBatch(fence=fence, records=tuple(records))) + assert replay.unchanged == len(records) and not replay.changes + async with sessions() as db: + tail = await query.changes(db, organization_id, sync_id, cursor=change_cursor) + assert not tail.changes + consumer = None + if name == "gmail" and os.environ.get("LIVE_ALMANAC_PYTHON"): + from almanac_handoff import verify_almanac_reader + + PROBE_STAGE = name + ":almanac_http_reader" + await engine.dispose() + consumer = await verify_almanac_reader( + records=listed, + storage=storage, + query=query, + sessions=sessions, + organization_id=organization_id, + sync_id=sync_id, + root=root, + ) + document_reader = None + if name == "google_drive" and os.environ.get("LIVE_WORKSPACE_DOCUMENT") == "1": + from workspace_document import verify_workspace_document_reader + + document_reader = await verify_workspace_document_reader( + records=listed, storage=storage, query=query, sessions=sessions, + organization_id=organization_id, sync_id=sync_id, root=root, + ) + return { + "document_reader": document_reader, + "almanac_reader": consumer, + "provider": name, + "identity_verification": identity_verification, + "observations": observations, + "partial_records": sum(record.completeness != "complete" for record in listed), + "record_types": sorted({record.identity.record_type for record in listed}), + "records": len(listed), + "journal_changes": len(changes), + "blobs": blob_count, + "blob_bytes": total_bytes, + "blob_sha_verified": True if blob_count else None, + "replay_new_changes": 0, + "fresh_connections_readback": True, + "full_scope_completed": False, + "checkpoint_saved": False, + } + + +async def main(): + """Allocate and destroy only private disposable local resources.""" + url = test_database_url() + key = os.environ["COMPOSIO_API_KEY"] + selected = os.environ.get("LIVE_PROVIDERS", "gmail,google_drive").split(",") + if not selected or set(selected) - { + "gmail", + "google_drive", + "google_calendar", + "slack", + "wispr", + }: + raise ValueError( + "LIVE_PROVIDERS must select gmail, google_drive, google_calendar, slack or wispr" + ) + if os.environ.get("LIVE_WORKSPACE_DOCUMENT") == "1" and selected != ["google_drive"]: + raise ValueError("Workspace document trial requires only google_drive") + inputs = [ + (name, os.environ[variable], os.environ["LIVE_EXPECTED_EMAIL"] if name != "wispr" else "") + for name, variable in ( + ("gmail", "LIVE_GMAIL_ACCOUNT_ID"), + ("google_drive", "LIVE_DRIVE_ACCOUNT_ID"), + ("google_calendar", "LIVE_CALENDAR_ACCOUNT_ID"), + ("slack", "LIVE_SLACK_ACCOUNT_ID"), + ("wispr", "LIVE_WISPR_ACCOUNT_ID"), + ) + if name in selected + ] + schema = "canonical_live_" + uuid4().hex + admin = create_async_engine(url) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": schema}}) + old_path = StoragePaths.TEMP_PROCESSING + old_umask = os.umask(0o077) + created = False + results = [] + try: + async with admin.begin() as connection: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + created = True + async with engine.begin() as connection: + for migration in ( + "0000_baseline.py", + "0001_canonical_records.py", + "0002_projection_publication.py", + "0003_mail_thread_index.py", + "0004_projection_generation.py", + "0005_capture_scan.py", + "0006_record_visibility.py", + "0007_scan_scope_owner.py", + "0008_scope_execution.py", + "0009_extraction_coverage.py", + "0010_owned_provisioning.py", + "0011_retained_projection_text.py", + "0012_scan_parent_verification.py", + ): + await connection.run_sync(migrate, migration) + with TemporaryDirectory(prefix="airweave-private-live-") as directory: + root = Path(directory) + os.chmod(root, 0o700) + StoragePaths.TEMP_PROCESSING = str(root / "downloads") + sessions = async_sessionmaker(engine, expire_on_commit=False) + for name, account, email in inputs: + result = await verify(name, account, email, key, sessions, engine, root) + results.append(result) + print(json.dumps({"verified_provider": result}), flush=True) + finally: + StoragePaths.TEMP_PROCESSING = old_path + os.umask(old_umask) + await engine.dispose() + if created: + async with admin.begin() as connection: + await connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + await admin.dispose() + print( + json.dumps( + {"verification": results, "schema_removed": True, "blob_directory_removed": True} + ) + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + # Never print SQL parameters, native payloads, credentials, or exception tracebacks. + details = {"failed": True, "error_type": type(error).__name__, "stage": PROBE_STAGE} + if isinstance(error, ValidationError): + # Types only: validation input/context can contain entire original messages. + details["validation_error_types"] = sorted( + {item["type"] for item in error.errors(include_input=False, include_context=False)} + ) + if isinstance(error, httpx.HTTPStatusError): + details["http_status"] = error.response.status_code + details["host"] = error.request.url.host + try: + native = error.response.json().get("error", {}) + reasons = [item.get("reason") for item in native.get("errors", [])] + allowed = { + "exportSizeLimitExceeded", + "cannotDownloadFile", + "insufficientFilePermissions", + "insufficientPermissions", + "fileNotDownloadable", + "downloadRestrictedForRevision", + "forbidden", + "rateLimitExceeded", + "userRateLimitExceeded", + } + details["recognized_provider_reasons"] = [r for r in reasons if r in allowed] + except (ValueError, AttributeError, TypeError): + pass + print(json.dumps(details)) + raise SystemExit(1) from None diff --git a/backend/tests/live/capture_comparison.py b/backend/tests/live/capture_comparison.py new file mode 100644 index 0000000000..f5bb3ee998 --- /dev/null +++ b/backend/tests/live/capture_comparison.py @@ -0,0 +1,114 @@ +"""Aggregate retained observations before disposable trials remove private evidence.""" + +from collections import Counter +from collections.abc import Mapping +from uuid import UUID + +from pydantic import JsonValue + +from airweave.domains.entities.canonical.models import SourceRecord + +FieldPath = tuple[str | int, ...] + + +def _changed_paths(before: JsonValue, after: JsonValue, path: FieldPath = ()) -> list[FieldPath]: + if type(before) is not type(after): + return [path] + if isinstance(before, dict) and isinstance(after, dict): + paths = [] + for key in before.keys() | after.keys(): + if key not in before or key not in after: + paths.append((*path, key)) + else: + paths.extend(_changed_paths(before[key], after[key], (*path, key))) + return paths + if isinstance(before, list) and isinstance(after, list): + if len(before) != len(after): + return [path] + return [ + changed + for index, (left, right) in enumerate(zip(before, after, strict=True)) + for changed in _changed_paths(left, right, (*path, index)) + ] + return [] if before == after else [path] + + +def _category(provider: str, path: FieldPath) -> str: + # Never emit a native key/path: providers may put private text in JSON keys. + if provider == "gmail": + if path and path[0] == "labelIds": + return "gmail_labels" + if path == ("historyId",): + return "gmail_history_id" + if path[:1] == ("payload",) and path[-2:] == ("body", "attachmentId"): + middle = path[1:-2] + if len(middle) % 2 == 0 and all( + middle[index] == "parts" and type(middle[index + 1]) is int + for index in range(0, len(middle), 2) + ): + return "gmail_attachment_locator" + return "other_payload" + + +def compare_observations( + before: Mapping[UUID, SourceRecord], after: Mapping[UUID, SourceRecord], provider: str +) -> dict[str, int | dict[str, int]]: + """Return counts only; raw snapshots, identifiers and SHA values stay in memory. + + Full snapshots remain in memory only for the bounded disposable harness. + Manifest comparison aligns part paths, sizes and SHA; a changed path alone is + not evidence of changed bytes. It concerns external blobs, not all bodies. Inline + bytes remain part of raw payload comparison. No inference from equal byte totals. + """ + counts = Counter( + matched_records=0, + changed_revision_records=0, + changed_payload_records=0, + locator_only_payload_records=0, + unchanged_records=0, + changed_revision_without_payload_or_blob_change=0, + records_with_retained_blobs=0, + equal_blob_manifest_records=0, + different_blob_manifest_records=0, + equal_blob_size_different_sha_records=0, + ) + categories = Counter() + for identity in before.keys() & after.keys(): + left, right = before[identity], after[identity] + counts["matched_records"] += 1 + changed_revision = left.revision != right.revision + counts["changed_revision_records"] += changed_revision + counts["unchanged_records"] += not changed_revision + paths = _changed_paths(left.payload, right.payload) + record_categories = [_category(provider, path) for path in paths] + categories.update(record_categories) + counts["changed_payload_records"] += bool(paths) + counts["locator_only_payload_records"] += bool(paths) and set(record_categories) == { + "gmail_attachment_locator" + } + if changed_revision and not paths and left.blobs == right.blobs: + counts["changed_revision_without_payload_or_blob_change"] += 1 + if left.blobs or right.blobs: + counts["records_with_retained_blobs"] += 1 + + # Native part paths align bytes without persisting or publishing those paths. + def manifest(record: SourceRecord, include_sha: bool) -> list[tuple[str, int, str]]: + return sorted( + (blob.source_path or "", blob.size_bytes, blob.sha256 if include_sha else "") + for blob in record.blobs + ) + + same_sha = manifest(left, True) == manifest(right, True) + counts["equal_blob_manifest_records"] += same_sha + counts["different_blob_manifest_records"] += not same_sha + counts["equal_blob_size_different_sha_records"] += not same_sha and manifest( + left, False + ) == manifest(right, False) + return { + "schema_version": 1, + "baseline_records": len(before), + "added_records": len(after.keys() - before.keys()), + "removed_rows": len(before.keys() - after.keys()), + **counts, + "changed_field_categories": dict(sorted(categories.items())), + } diff --git a/backend/tests/live/capture_resume.py b/backend/tests/live/capture_resume.py new file mode 100644 index 0000000000..463a3c6c12 --- /dev/null +++ b/backend/tests/live/capture_resume.py @@ -0,0 +1,157 @@ +"""Test-only process-loss injection after a verified production page commit.""" + +import json +import os +from pathlib import Path +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.models.capture_scan import CaptureScan +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob + + +class ResumeTarget(BaseModel): + """Private test evidence references SQL state; no duplicated cursor or record payload.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + scan_id: UUID + cycle_id: UUID + sweep_id: UUID + revision: int + + +class PageResumeProbe: + """The child exits before its next provider call, leaving the actual job RUNNING.""" + + def __init__( + self, + sessions, + organization_id, + sync_id, + job_id, + attempt_id, + target_path: Path, + mode: Literal["interrupt", "resume"], + counters, + ): + self.sessions = sessions + self.organization_id, self.sync_id = organization_id, sync_id + self.job_id, self.attempt_id = job_id, attempt_id + self.target_path, self.mode, self.counters = target_path, mode, counters + self.resumed_saved_page = False + self.saved_cursor_expired = False + self._checking_saved_page = False + self.target = ( + ResumeTarget.model_validate_json(target_path.read_text()) if mode == "resume" else None + ) + + async def before_page(self, scope: CompletedScope, continuation: ScanContinuation): + """Verify committed pending replies and writer/cycle ownership before fault injection.""" + self._checking_saved_page = False + if scope.record_type != "message" or not continuation.value.get("pending_threads"): + return + async with self.sessions() as db: + row = await db.scalar( + select(CaptureScan).where( + CaptureScan.organization_id == self.organization_id, + CaptureScan.sync_id == self.sync_id, + CaptureScan.record_type == scope.record_type, + CaptureScan.container_id == scope.container_id, + ) + ) + sync = await db.get(Sync, self.sync_id) + job = await db.get(SyncJob, self.job_id) + cursor = await db.scalar( + select(SyncCursor).where( + SyncCursor.organization_id == self.organization_id, + SyncCursor.sync_id == self.sync_id, + ) + ) + if row is None or sync is None or job is None or cursor is None: + raise AssertionError("Durable interruption state is missing") + cycle = CaptureCycle.model_validate(cursor.cursor_data["canonical_cycle"]) + assert sync.organization_id == self.organization_id + assert sync.writer_job_id == self.job_id and sync.writer_attempt_id == self.attempt_id + assert job.organization_id == self.organization_id and job.sync_id == self.sync_id + assert job.status == "running" and cycle.phase == "active" + root = await db.scalar( + select(CaptureScan).where( + CaptureScan.organization_id == self.organization_id, + CaptureScan.sync_id == self.sync_id, + CaptureScan.cycle_id == cycle.version.cycle_id, + CaptureScan.record_type == "channel", + CaptureScan.parent_record_id.is_(None), + CaptureScan.container_id.is_(None), + ) + ) + assert root is not None and root.phase == "complete" + assert root.membership_attempt_id == self.attempt_id + assert row.cycle_id == cycle.version.cycle_id and row.phase == "collecting" + assert row.continuation == continuation.value + target = ResumeTarget( + scan_id=row.id, cycle_id=row.cycle_id, sweep_id=row.sweep_id, revision=row.revision + ) + if self.mode == "interrupt": + self.target_path.write_text(target.model_dump_json()) + os.chmod(self.target_path, 0o600) + print( + json.dumps( + { + "failed": False, + "counters_complete": True, + "intentional_interruption": True, + "durable_pending_threads_verified": True, + "job_status": "running", + "full_scope_completed": False, + "checkpoint_saved": False, + **self.counters, + } + ), + flush=True, + ) + os._exit(75) # Test child only: simulate process loss without cancellation transitions. + if target == self.target: + self.resumed_saved_page = True + self._checking_saved_page = True + + def cursor_expired(self): + """A resumed request that is rejected is distinct from successful cursor reuse.""" + if self._checking_saved_page: + self.saved_cursor_expired = True + + +async def inspect_resume_progress(sessions, organization_id, sync_id, job_id, target_path: Path): + """Parent-side readback survives child death without copying provider content or cursors.""" + target = ResumeTarget.model_validate_json(target_path.read_text()) + async with sessions() as db: + row = await db.scalar( + select(CaptureScan).where( + CaptureScan.id == target.scan_id, + CaptureScan.organization_id == organization_id, + CaptureScan.sync_id == sync_id, + ) + ) + sync = await db.get(Sync, sync_id) + job = await db.get(SyncJob, job_id) + advanced = bool( + row is not None + and sync is not None + and job is not None + and sync.organization_id == organization_id + and job.organization_id == organization_id + and job.sync_id == sync_id + and sync.writer_job_id == job_id + and sync.writer_attempt_number == 2 + and row.cycle_id == target.cycle_id + and row.sweep_id == target.sweep_id + and row.revision > target.revision + ) + return {"resumed_page_committed": advanced} diff --git a/backend/tests/live/evaluation.py b/backend/tests/live/evaluation.py new file mode 100644 index 0000000000..1f7f257afc --- /dev/null +++ b/backend/tests/live/evaluation.py @@ -0,0 +1,407 @@ +# ruff: noqa: E402 +"""Opt-in retained local evaluation, using production capture, index and HTTP services.""" + +import argparse +import asyncio +import json +import os +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path +from tempfile import NamedTemporaryFile +from uuid import UUID, uuid4 + +os.environ.update( + DENSE_EMBEDDER="local_minilm", + EMBEDDING_DIMENSIONS="384", + SPARSE_EMBEDDER="fastembed_bm25", + TEXT2VEC_INFERENCE_URL="http://127.0.0.1:9878", + REDIS_PORT="16379", + DISABLE_RATE_LIMIT="true", +) + +import canonical_capture as capture # noqa: E402 +from pydantic import BaseModel, ConfigDict, Field, SecretStr +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from airweave.core import credentials +from airweave.core.config import AuthMode, settings +from airweave.core.datetime_utils import utc_now_naive +from airweave.core.logging import logger +from airweave.domains.entities.canonical.projection_store import CanonicalProjectionStore +from airweave.domains.entities.canonical.projector import CanonicalProjector +from airweave.domains.entities.canonical.search_metadata import SEARCH_METADATA_PIPELINE_VERSION +from airweave.domains.sync_pipeline.processors.chunk_embed import ChunkEmbedProcessor +from airweave.models import Entity, Organization, Sync +from airweave.models.api_key import APIKey +from airweave.models.collection import Collection +from airweave.models.source_connection import SourceConnection +from airweave.models.vector_db_deployment_metadata import VectorDbDeploymentMetadata +from airweave.platform.configs.config import ( + GmailConfig, + GoogleCalendarConfig, + GoogleDriveConfig, + SlackConfig, +) +from airweave.platform.destinations.vespa.destination import VespaDestination + + +class Source(BaseModel): + model_config = ConfigDict(extra="forbid") + provider: str + account_id: UUID = Field(default_factory=uuid4) + organization_id: UUID + sync_id: UUID + source_connection_id: UUID + external_account_id: str + external_user_id: str | None = None + label: str + api_key: SecretStr + + +class Manifest(BaseModel): + model_config = ConfigDict(extra="forbid") + schema_name: str = Field(pattern=r"^canonical_eval_[a-f0-9]{32}$") + sources: list[Source] = Field(default_factory=list) + encryption_key: SecretStr + state_secret: SecretStr + origin: str = "http://127.0.0.1:18081" + + +def private_root(value: str, *, create: bool = False) -> Path: + root = Path(value).absolute() + if root.is_symlink(): + raise ValueError("Evaluation directory must not be a symlink") + if create: + root.mkdir(mode=0o700, parents=False, exist_ok=False) + if not root.is_dir() or root.stat().st_uid != os.getuid() or root.stat().st_mode & 0o077: + raise ValueError("Evaluation directory must be private and current-user owned") + return root + + +def save(root: Path, manifest: Manifest) -> None: + """Secrets enter only the explicit mode0600 handoff, never stdout.""" + value = manifest.model_dump(mode="json") + value["encryption_key"] = manifest.encryption_key.get_secret_value() + value["state_secret"] = manifest.state_secret.get_secret_value() + for row, source in zip(value["sources"], manifest.sources, strict=True): + row["api_key"] = source.api_key.get_secret_value() + with NamedTemporaryFile(mode="w", dir=root, prefix=".manifest-", delete=False) as staged: + temporary = Path(staged.name) + try: + json.dump(value, staged) + staged.flush() + os.fsync(staged.fileno()) + temporary.replace(root / "manifest.json") + finally: + temporary.unlink(missing_ok=True) + + +def configure(root: Path, manifest: Manifest): + """Production container; no authentication dependency overrides.""" + from airweave.core import container + + settings.AUTH_MODE = AuthMode.API_KEY + settings.ENCRYPTION_KEY = manifest.encryption_key.get_secret_value() + settings.STATE_SECRET = manifest.state_secret.get_secret_value() + settings.DENSE_EMBEDDER = "local_minilm" + settings.EMBEDDING_DIMENSIONS = 384 + settings.SPARSE_EMBEDDER = "fastembed_bm25" + settings.TEXT2VEC_INFERENCE_URL = "http://127.0.0.1:9878" + settings.VESPA_URL = "http://127.0.0.1" + settings.VESPA_PORT = 8081 + settings.STORAGE_BACKEND = "filesystem" + settings.STORAGE_PATH = str(root / "blobs") + settings.REDIS_PORT = 16379 + settings.DISABLE_RATE_LIMIT = True + container.initialize_container(settings) + return container.container + + +def capture_configuration(provider): + if provider == "gmail": + native = os.environ["LIVE_EXPECTED_EMAIL"] + return ( + GmailConfig(expected_mailbox=native, gmail_query="newer_than:1d smaller:100K"), + native, + None, + ) + if provider == "google_drive": + native = os.environ["LIVE_DRIVE_PERMISSION_ID"] + return GoogleDriveConfig(expected_permission_id=native), native, None + if provider == "slack": + native, user = os.environ["LIVE_SLACK_TEAM_ID"], os.environ["LIVE_SLACK_USER_ID"] + return SlackConfig(expected_team_id=native, expected_user_id=user), native, user + if provider == "google_calendar": + native = os.environ["LIVE_CALENDAR_PRIMARY_ID"] + now = datetime.now(timezone.utc).replace(microsecond=0) + config = GoogleCalendarConfig( + expected_primary_calendar_id=native, + calendar_ids=[native], + occurrence_window={"start": now, "end": now + timedelta(days=7)}, + ) + return config, native, None + raise ValueError("Provider requires a native identity before evaluation binding") + + +async def capture_corpus(root: Path, manifest: Manifest, sessions, providers): + runtime = configure(root, manifest) + projector = CanonicalProjector( + CanonicalProjectionStore(), + sessions, + ChunkEmbedProcessor( + runtime.converter_registry, runtime.dense_embedder, runtime.sparse_embedder + ), + runtime.storage_backend, + ) + for provider in providers: + if any(source.provider == provider for source in manifest.sources): + raise ValueError("Source already captured; resume its existing cursor instead") + config, native, native_user = capture_configuration(provider) + from provider_lifecycle import child + + async with sessions() as db: + organization = Organization(name="Private bounded UI evaluation") + db.add(organization) + await db.flush() + sync = Sync( + organization_id=organization.id, + name="Bounded live " + provider, + index_pipeline_version=SEARCH_METADATA_PIPELINE_VERSION, + ) + db.add(sync) + await db.commit() + # Production durable-page lifecycle; bounds interrupt without inventing completion. + outcome = await child( + { + "schema": manifest.schema_name, + "root": str(root), + "provider": provider, + "organization_id": str(organization.id), + "sync_id": str(sync.id), + "request_limit": 180, + "record_limit": 500, + "timeout": 180, + "blob_byte_limit": 50 * 1024 * 1024, + "file_byte_limit": 10 * 1024 * 1024, + "query": "newer_than:1d smaller:100K", + **( + {"calendar_config": config.model_dump(mode="json")} + if provider == "google_calendar" + else {} + ), + } + ) + async with sessions() as db: + count = await db.scalar( + select(func.count()).select_from(Entity).where(Entity.sync_id == sync.id) + ) + if not count: + raise ValueError("Bounded lifecycle retained no original records") + evidence = {"records": count, "lifecycle_exit": outcome, "bounded_evaluation": True} + deployment = await db.scalar(select(VectorDbDeploymentMetadata)) + if deployment is None: + deployment = VectorDbDeploymentMetadata( + dense_embedder="local_minilm", + embedding_dimensions=384, + sparse_embedder="fastembed_bm25", + ) + db.add(deployment) + await db.flush() + if ( + deployment.dense_embedder, + deployment.embedding_dimensions, + deployment.sparse_embedder, + ) != ("local_minilm", 384, "fastembed_bm25"): + raise ValueError("Evaluation embedding deployment mismatch") + collection = Collection( + name="Bounded private evaluation", + readable_id="evaluation-" + uuid4().hex, + organization_id=sync.organization_id, + vector_db_deployment_metadata_id=deployment.id, + ) + db.add(collection) + await db.flush() + connection = SourceConnection( + name="Bounded " + provider, + short_name=provider, + config_fields=config.model_dump(mode="json"), + organization_id=sync.organization_id, + readable_collection_id=collection.readable_id, + sync_id=sync.id, + is_authenticated=True, + ) + db.add(connection) + key = secrets.token_urlsafe(32) + db.add( + APIKey( + organization_id=sync.organization_id, + encrypted_key=credentials.encrypt({"key": key}), + expiration_date=utc_now_naive() + timedelta(days=1), + ) + ) + await db.commit() + source = Source( + provider=provider, + organization_id=sync.organization_id, + sync_id=sync.id, + source_connection_id=connection.id, + external_account_id=native, + external_user_id=native_user, + label=os.environ["LIVE_EXPECTED_EMAIL"], + api_key=SecretStr(key), + ) + manifest.sources.append(source) + save(root, manifest) + destination = await VespaDestination.create( + collection_id=collection.id, organization_id=sync.organization_id, logger=logger + ) + try: + after = None + while True: + batch = await projector.batch( + sync.organization_id, sync.id, provider, destination, logger, after_id=after + ) + print( + json.dumps( + { + "provider": provider, + "capture": evidence, + "projection": batch.model_dump(mode="json"), + } + ), + flush=True, + ) + if not batch.has_more: + break + after = batch.after_id + + finally: + await destination.close_connection() + + +async def project(root: Path, manifest: Manifest, sessions): + runtime = configure(root, manifest) + projector = CanonicalProjector( + CanonicalProjectionStore(), + sessions, + ChunkEmbedProcessor( + runtime.converter_registry, runtime.dense_embedder, runtime.sparse_embedder + ), + runtime.storage_backend, + ) + for source in manifest.sources: + async with sessions() as db: + collection_id = await db.scalar( + select(Collection.id) + .join( + SourceConnection, + SourceConnection.readable_collection_id == Collection.readable_id, + ) + .where(SourceConnection.id == source.source_connection_id) + ) + destination = await VespaDestination.create( + collection_id=collection_id, organization_id=source.organization_id, logger=logger + ) + try: + after = None + while True: + batch = await projector.batch( + source.organization_id, + source.sync_id, + source.provider, + destination, + logger, + after_id=after, + ) + print( + json.dumps( + {"provider": source.provider, "projection": batch.model_dump(mode="json")} + ), + flush=True, + ) + if not batch.has_more: + break + after = batch.after_id + finally: + await destination.close_connection() + + +async def serve(root: Path, manifest: Manifest, sessions): + """Serve the production HTTP app without running schedules or startup services.""" + import uvicorn + + from airweave.db import session + + runtime = configure(root, manifest) + session.AsyncSessionLocal = sessions + from airweave.main import app + + app.state.http_metrics = runtime.metrics.http + await uvicorn.Server( + uvicorn.Config( + app, + host="127.0.0.1", + port=18081, + access_log=False, + log_config=None, + lifespan="off", + ) + ).serve() + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("capture", "extend", "project", "serve")) + parser.add_argument("--directory", required=True) + parser.add_argument("--provider", choices=("google_calendar", "slack")) + args = parser.parse_args() + if (args.command == "extend") != (args.provider is not None): + parser.error("--provider is required only for extend") + os.umask(0o077) + root = private_root(args.directory, create=args.command == "capture") + if args.command == "capture": + from cryptography.fernet import Fernet + + manifest = Manifest( + schema_name="canonical_eval_" + uuid4().hex, + encryption_key=SecretStr(Fernet.generate_key().decode()), + state_secret=SecretStr(secrets.token_urlsafe(32)), + ) + save(root, manifest) + else: + manifest = Manifest.model_validate_json((root / "manifest.json").read_text()) + url = capture.test_database_url() + engine = create_async_engine( + url, connect_args={"server_settings": {"search_path": manifest.schema_name}} + ) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + if args.command == "capture": + async with engine.begin() as db: + await db.execute(text(f'CREATE SCHEMA "{manifest.schema_name}"')) + for path in sorted((Path(__file__).parents[2] / "alembic/versions").glob("*.py")): + if path.name[:4].isdigit(): + await db.run_sync(capture.migrate, path.name) + capture.StoragePaths.TEMP_PROCESSING = str(root / "downloads") + await capture_corpus(root, manifest, sessions, ("gmail", "google_drive")) + elif args.command == "extend": + if args.provider is None: + raise ValueError("Extend requires an explicit provider") + capture.StoragePaths.TEMP_PROCESSING = str(root / "downloads") + await capture_corpus(root, manifest, sessions, (args.provider,)) + elif args.command == "project": + await project(root, manifest, sessions) + else: + await serve(root, manifest, sessions) + finally: + await engine.dispose() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(json.dumps({"failed": True, "error_type": type(error).__name__})) + raise SystemExit(1) from None diff --git a/backend/tests/live/evidence/calendar-scoped-cycles-20260930.json b/backend/tests/live/evidence/calendar-scoped-cycles-20260930.json new file mode 100644 index 0000000000..51c56a543d --- /dev/null +++ b/backend/tests/live/evidence/calendar-scoped-cycles-20260930.json @@ -0,0 +1,54 @@ +{ + "commit": "826a2c1db50966509251206ff8a9e19eafdf18ab", + "almanac_commit": "c31ac0d9b0f7d9c25e052f8c5446db055d9da654", + "provider": "google_calendar", + "verification": "Real read-only Google provider; production capture pipeline; disposable local PostgreSQL; two fresh worker processes and real loopback HTTP consumer", + "first": { + "job_status": "completed", + "provider_requests": 4, + "records_observed": 188, + "stored_records": 181, + "partial_records": 0, + "observed_change_sequence": 188, + "loaded_durable_checkpoint": false + }, + "second": { + "job_status": "completed", + "provider_requests": 4, + "records_observed": 20, + "stored_records": 181, + "partial_records": 0, + "observed_change_sequence": 188, + "loaded_durable_checkpoint": true, + "sync_token_requests": 1, + "identical_payload_revision_digest": true, + "scope_summary": { + "eligible": 3, + "completed_full": 2, + "completed_changes": 1, + "unfinished": 0 + } + }, + "almanac_consumer": { + "verified": true, + "events": 19, + "pages": 4, + "native_identities_verified": true, + "coverage_verified": true, + "wrong_source_denied": true, + "uncaptured_range_denied": true + }, + "harness_exit_code": 0, + "independently_verified_cleanup": { + "remaining_private_schemas": 0, + "remaining_private_directories": 0, + "archives_removed": true + }, + "limitations": [ + "Selected primary calendar and fixed seven-day occurrence window only", + "No live induced edits, deletion, revocation or all-day/DST example", + "Loopback fixture authentication; no hosted WorkOS owner proof", + "No staging deployment" + ], + "harness_summary_note": "Generic identical_second_read and saved_cursor_reused summarize full-scope/recovery trials and are false for mixed Calendar; per-cycle native token assertions and matching revision digests establish this delta proof." +} diff --git a/backend/tests/live/evidence/docs-almanac-cli-20260930.json b/backend/tests/live/evidence/docs-almanac-cli-20260930.json new file mode 100644 index 0000000000..036ebbeb48 --- /dev/null +++ b/backend/tests/live/evidence/docs-almanac-cli-20260930.json @@ -0,0 +1,36 @@ +{ + "date": "2026-09-30", + "airweave_commit": "69d0296", + "almanac_commit": "8ec369087", + "provider_requests": 5, + "records": 1, + "journal_changes": 1, + "blobs": 3, + "blob_bytes": 1053474, + "partial_records": 0, + "blob_sha_verified": true, + "replay_new_changes": 0, + "fresh_connections_readback": true, + "native_document_digest_verified": true, + "top_level_tabs": 4, + "identity_verified": true, + "stale_revision_denied": true, + "wrong_account_denied": true, + "revoked_binding_denied": true, + "scope": "actual_cli_and_almanac_fixture_route_to_real_store_http", + "full_scope_completed": false, + "checkpoint_saved": false, + "harness_exit_code": 0, + "cleanup": { + "private_schemas": 0, + "private_blob_directories": 0, + "immutable_archives_removed": true + }, + "limitations": [ + "One selected document, not full source traversal or checkpoint", + "Almanac route/auth/binding use fixtures; source-store uses real loopback HTTP", + "No hosted ownership proof", + "No induced provider edits/deletes", + "No Sheets capture proof" + ] +} diff --git a/backend/tests/live/evidence/docs-stored-http-20260930.json b/backend/tests/live/evidence/docs-stored-http-20260930.json new file mode 100644 index 0000000000..3732417501 --- /dev/null +++ b/backend/tests/live/evidence/docs-stored-http-20260930.json @@ -0,0 +1,40 @@ +{ + "date": "2026-09-30", + "commit": "85603b0", + "provider": "google_drive", + "provider_requests": 5, + "identity_verification": "provider_email", + "records": 1, + "journal_changes": 1, + "partial_records": 0, + "blobs": 3, + "blob_bytes": 1053473, + "blob_sha_verified": true, + "replay_new_changes": 0, + "fresh_connections_readback": true, + "document_reader": { + "verified": true, + "native_document_digest_verified": true, + "identity_verified": true, + "stale_revision_denied": true, + "wrong_source_denied": true, + "top_level_tabs": 4, + "scope": "stored_document_http_route_with_fixture_auth" + }, + "full_scope_completed": false, + "checkpoint_saved": false, + "almanac_reader": null, + "harness_exit_code": 0, + "cleanup": { + "remaining_private_schemas": 0, + "remaining_private_blob_directories": 0, + "immutable_archive_removed": true + }, + "limitations": [ + "One selected document, not full Drive enumeration or incremental-cycle proof", + "Local HTTP route with fixture authentication, not hosted ownership verification", + "No Almanac document CLI consumer tested", + "No induced provider mutation or deletion", + "Sheets not tested" + ] +} diff --git a/backend/tests/live/evidence/drive-lifecycle-20260930.json b/backend/tests/live/evidence/drive-lifecycle-20260930.json new file mode 100644 index 0000000000..4a7ff8f85d --- /dev/null +++ b/backend/tests/live/evidence/drive-lifecycle-20260930.json @@ -0,0 +1,23 @@ +{ + "date": "2026-09-30", + "scope": "full accessible allDrives corpus; no hidden filter", + "provider_writes": false, + "limits": {"records": 2000, "provider_requests": 250, "total_blob_bytes": 134217728, "per_blob_bytes": 10485760, "seconds": 300}, + "attempts": 2, + "second_attempt_purpose": "sanitized diagnostic retry of same full scope", + "result_each_attempt": { + "failed": true, + "error_type": "ComposioProxyError", + "provider_requests": 11, + "records_observed": 4, + "started": 1, + "completed": 0, + "checkpoint_unchanged": true, + "job_status": "failed" + }, + "diagnostic_retry": {"safe_reason": "Proxy download exceeds the file size limit", "last_operation": "download"}, + "fresh_process_incremental_run": false, + "full_scope_verified": false, + "cleanup_each_attempt": {"schema_removed": true, "blob_directory_removed": true}, + "drive_source_tests_passed": 13 +} diff --git a/backend/tests/live/evidence/drive-lifecycle-production-limit-20260930.json b/backend/tests/live/evidence/drive-lifecycle-production-limit-20260930.json new file mode 100644 index 0000000000..1cb5fa8e8f --- /dev/null +++ b/backend/tests/live/evidence/drive-lifecycle-production-limit-20260930.json @@ -0,0 +1,16 @@ +{ + "date": "2026-09-30", + "scope": "full accessible allDrives corpus; no hidden filter", + "provider_writes": false, + "limits": {"records": 2000, "provider_requests": 250, "total_blob_bytes": 536870912, "per_blob_bytes": 209715200, "seconds": 300, "minimum_free_disk_bytes": 2147483648}, + "free_disk_bytes_before": 9525235712, + "runs": [ + {"mode": "drive_initial", "provider_requests": 85, "records_observed": 55, "stored_records": 55, "not_content_complete_records": 3, "blob_bytes_written": 207597309, "started": 1, "completed": 1, "checkpoint_saved": true, "observed_change_sequence": 55, "loaded_durable_checkpoint": false, "job_status": "completed", "payload_revision_digest": "5e6c392143053de4d41a79b229ea1eab2b884b98be72b28c4ce6cb9cdb4aa938"}, + {"mode": "drive_incremental", "provider_requests": 2, "records_observed": 0, "stored_records": 55, "not_content_complete_records": 3, "blob_bytes_written": 0, "started": 0, "completed": 0, "resumed_changes_requests": 1, "checkpoint_saved": true, "observed_change_sequence": 55, "loaded_durable_checkpoint": true, "job_status": "completed", "payload_revision_digest": "5e6c392143053de4d41a79b229ea1eab2b884b98be72b28c4ce6cb9cdb4aa938"} + ], + "identical_second_read": true, + "cleanup": {"schema_removed": true, "blob_directory_removed": true}, + "verified": ["real local orchestrator", "real job state transitions", "durable canonical checkpoint", "fresh-process native changes-token resume", "free disk reserve boundaries"], + "not_verified": ["content completeness for three explicitly incomplete records", "hosted auth or real product binding", "Temporal worker or hosted factory", "Drive product HTTP consumer", "live induced deletion/revocation", "search quality"], + "earlier_smaller_budget_failure": "drive-lifecycle-20260930.json" +} diff --git a/backend/tests/live/evidence/drive-page-cycles-20260930.json b/backend/tests/live/evidence/drive-page-cycles-20260930.json new file mode 100644 index 0000000000..203cb4433d --- /dev/null +++ b/backend/tests/live/evidence/drive-page-cycles-20260930.json @@ -0,0 +1,39 @@ +{ + "commit": "46172672bcc721fd8bd8b0761d3d28dd4c7d97e1", + "provider": "google_drive", + "verification": "real provider read-only; actual production capture pipeline; disposable local PostgreSQL; two fresh processes", + "first": { + "job_status": "completed", + "provider_requests": 140, + "records_observed": 55, + "stored_records": 55, + "partial_records": 3, + "blob_bytes_written": 207618914, + "observed_change_sequence": 55, + "loaded_durable_checkpoint": false + }, + "second": { + "job_status": "completed", + "provider_requests": 2, + "records_observed": 0, + "stored_records": 55, + "partial_records": 3, + "blob_bytes_written": 0, + "observed_change_sequence": 55, + "loaded_durable_checkpoint": true, + "resumed_changes_requests": 1, + "identical_payload_revision_digest": true + }, + "harness_exit_code": 0, + "independently_verified_cleanup": { + "remaining_private_schemas": 0, + "remaining_private_directories": 0, + "archive_removed": true + }, + "limitations": [ + "Three records remain content-incomplete", + "No live induced edit/deletion/revocation", + "No hosted Almanac authorization or consumer proof", + "No staging deployment" + ] +} diff --git a/backend/tests/live/evidence/drive-page-harness-gap-20260930.json b/backend/tests/live/evidence/drive-page-harness-gap-20260930.json new file mode 100644 index 0000000000..8b1004e5b1 --- /dev/null +++ b/backend/tests/live/evidence/drive-page-harness-gap-20260930.json @@ -0,0 +1,20 @@ +{ + "source_commit": "35cb5fb", + "verified_at": "2026-09-30", + "provider": "google_drive", + "harness_exit_code": 1, + "job_status": "completed", + "error_type": "KeyError", + "identified_harness_gap": "validate_checkpoint expects removed legacy canonical_page_token; new adapter publishes canonical_cycle checkpoint", + "provider_requests": 140, + "records_observed": 55, + "blob_bytes_written": 207614203, + "second_cycle_ran": false, + "complete_trial_verified": false, + "schema_removed": true, + "blob_directory_removed": true, + "independent_private_schemas": 0, + "independent_private_directories": 0, + "immutable_archive_removed": true, + "provider_writes": false +} diff --git a/backend/tests/live/evidence/gmail-lifecycle-20260930.json b/backend/tests/live/evidence/gmail-lifecycle-20260930.json new file mode 100644 index 0000000000..7e7ba1f242 --- /dev/null +++ b/backend/tests/live/evidence/gmail-lifecycle-20260930.json @@ -0,0 +1,64 @@ +{ + "date": "2026-09-30", + "mode": "filtered_full_reconciliation", + "scope": "fixed seven-day after/before epoch query; no size/category filter", + "provider_writes": false, + "runs": [ + { + "process": 1, + "provider_requests": 287, + "records_observed": 239, + "stored_records": 239, + "partial_records": 0, + "scope_started": 1, + "scope_completed": 1, + "blob_bytes_written": 5501801, + "checkpoint_saved": true, + "loaded_durable_checkpoint": false, + "job_status": "completed", + "payload_revision_digest": "06bec711f3e00a2101e68c1b8fd56120f91a4c18cc4ecdff54e52f015a17a556" + }, + { + "process": 2, + "provider_requests": 287, + "records_observed": 239, + "stored_records": 239, + "partial_records": 0, + "scope_started": 1, + "scope_completed": 1, + "blob_bytes_written": 5501801, + "checkpoint_saved": true, + "loaded_durable_checkpoint": true, + "observed_change_sequence": 270, + "job_status": "completed", + "payload_revision_digest": "d282ee7546026cfdc02af2bc3da16010bd94271eec5ef9bb2666a85339f79d83" + } + ], + "identical_second_read": false, + "additional_changes": 31, + "repeated_native_get_diagnostic": { + "same_message": true, + "changed_paths": [ + "/payload/parts/1/body/attachmentId", + "/payload/parts/2/body/attachmentId" + ], + "limitation": "One immediate repeated GET isolated native attachment locator volatility; it does not attribute every earlier change." + }, + "cleanup": { + "remaining_live_schemas": 0, + "remaining_private_live_directories": 0 + }, + "verification": { + "gmail_source_tests_passed": 15, + "postgres_pipeline_tests_passed": 4, + "simulated_budget_guard": "251st observation raises before completed scope" + }, + "not_verified": [ + "Gmail history-delta mode", + "whole mailbox completeness", + "Temporal worker or hosted factory", + "hosted authentication or real product binding", + "live induced failure/recovery", + "search quality" + ] +} diff --git a/backend/tests/live/evidence/gmail-query-20260930.json b/backend/tests/live/evidence/gmail-query-20260930.json new file mode 100644 index 0000000000..c8d101d9ac --- /dev/null +++ b/backend/tests/live/evidence/gmail-query-20260930.json @@ -0,0 +1,26 @@ +{ + "verified_at": "2026-09-30", + "source_commit": "cd423b8", + "provider": "gmail", + "mode": "frozen_seven_day_query_two_cycle_attempt", + "provider_writes": false, + "completed_runs": 0, + "attempted_runs": 1, + "harness_exit_code": 1, + "error_type": "SourceServerError", + "last_operation": "metadata", + "provider_requests": 184, + "records_observed": 100, + "blob_bytes_written": 3482736, + "full_scope_completed": false, + "schema_removed": true, + "blob_directory_removed": true, + "independent_remaining_private_schemas": 0, + "independent_remaining_private_live_directories": 0, + "immutable_archive_removed": true, + "limitations": [ + "Second cycle did not run.", + "No complete query scope or subsequent cycle proof.", + "Error type identifies an upstream server failure; originating provider versus authentication proxy was not established by sanitized output." + ] +} diff --git a/backend/tests/live/evidence/gmail-query-retry-fixed-20260930.json b/backend/tests/live/evidence/gmail-query-retry-fixed-20260930.json new file mode 100644 index 0000000000..70a6eeecea --- /dev/null +++ b/backend/tests/live/evidence/gmail-query-retry-fixed-20260930.json @@ -0,0 +1,46 @@ +{ + "verified_at": "2026-09-30", + "source_commit": "1b28bf7", + "provider": "gmail", + "mode": "frozen_seven_day_filtered_full_reconciliation", + "provider_writes": false, + "harness_exit_code": 0, + "runs": [ + { + "run": 1, + "provider_requests": 302, + "records_observed": 242, + "stored_records": 242, + "partial_records": 0, + "blob_bytes_written": 5660964, + "full_scope_completed": true, + "loaded_durable_checkpoint": false, + "change_sequence": 242 + }, + { + "run": 2, + "provider_requests": 302, + "records_observed": 242, + "stored_records": 242, + "partial_records": 0, + "blob_bytes_written": 5660964, + "full_scope_completed": true, + "loaded_durable_checkpoint": true, + "change_sequence": 277 + } + ], + "aggregate_requests": 604, + "aggregate_observations": 484, + "identical_second_read": false, + "schema_removed": true, + "blob_directory_removed": true, + "independent_private_directories": 0, + "independent_private_schemas": 0, + "immutable_archive_removed": true, + "limitations": [ + "Frozen seven-day query, not whole-mailbox baseline or history-delta proof.", + "Fresh second job loads durable cycle state; this trial does not interrupt a page.", + "Second raw payload/revision digest changed; no claim of identical provider responses.", + "Local disposable database and capture harness, not hosted owner-binding or staging proof." + ] +} diff --git a/backend/tests/live/evidence/gmail-recovery-20260930.json b/backend/tests/live/evidence/gmail-recovery-20260930.json new file mode 100644 index 0000000000..87ddcd31d3 --- /dev/null +++ b/backend/tests/live/evidence/gmail-recovery-20260930.json @@ -0,0 +1,68 @@ +{ + "verified_at": "2026-09-30", + "source_commit": "6c45db8", + "provider": "gmail", + "mode": "bounded_unfiltered_same_job_recovery", + "provider_writes": false, + "limits": { + "requests": 600, + "observations": 250, + "seconds": 600, + "blob_bytes": 268435456 + }, + "initial_attempt": { + "source_commit": "75d85b5", + "harness_exit_code": 1, + "error_type": "AttributeError", + "identified_harness_gap": "missing force_full_sync context field", + "provider_requests": 1, + "records_observed": 0, + "schema_removed": true, + "blob_directory_removed": true + }, + "stages": [ + { + "stage": "interrupt", + "intentional_exit": 75, + "provider_requests": 68, + "records_observed": 50, + "identity_profile_requests": 1, + "capture_profile_requests": 1, + "blob_bytes_written": 3039870, + "full_scope_completed": false, + "checkpoint_saved": false + }, + { + "stage": "resume", + "intentional_exit": 76, + "provider_requests": 2, + "records_observed": 0, + "identity_profile_requests": 1, + "capture_profile_requests": 0, + "blob_bytes_written": 0, + "resumed_saved_page": true, + "saved_cursor_expired": false, + "resumed_page_committed": true, + "full_scope_completed": false, + "checkpoint_saved": false + } + ], + "successful_trial": { + "harness_exit_code": 0, + "provider_requests": 70, + "records_observed": 50, + "gmail_recovery_verified": true, + "schema_removed": true, + "blob_directory_removed": true + }, + "independent_cleanup": { + "remaining_private_schemas": 0, + "remaining_private_live_directories": 0, + "immutable_archive_removed": true + }, + "limitations": [ + "Resumed commit was an empty history-check page; no additional messages were captured by the resumed process.", + "No completed full-mailbox baseline, subsequent change cycle, deletion or hosted owner-binding proof.", + "This trial uses local disposable PostgreSQL and fixture collaborators for non-capture services; it is not a staging deployment." + ] +} diff --git a/backend/tests/live/evidence/google-workspace-access-20260930.json b/backend/tests/live/evidence/google-workspace-access-20260930.json new file mode 100644 index 0000000000..814ff07066 --- /dev/null +++ b/backend/tests/live/evidence/google-workspace-access-20260930.json @@ -0,0 +1,40 @@ +{ + "probe": "native_google_workspace_read_access", + "date": "2026-09-30", + "transport_revision": "f47eb90", + "temporary_probe_sha256": "1295b94da8446e7b517aeee4b7cc0457ceeb04cf6a857f5ba7194215152dd1bb", + "read_only": true, + "actual_http_calls": 5, + "call_limit": 6, + "timeout_seconds": 150, + "identity_checks": [ + "Exact Composio connected-account ID", + "Active googledrive toolkit", + "Native Drive account email matches selected account" + ], + "docs": { + "sample_source": "Drive files.list, MIME document, pageSize1", + "native_request": "GET docs.googleapis.com/v1/documents/{same_file_id}?includeTabsContent=true", + "status": 200, + "document_id_matches_drive_file": true, + "response_bytes": 47353, + "tabs_array_returned": true + }, + "sheets": { + "sample_source": "Drive files.list, MIME spreadsheet, pageSize1", + "sample_returned": false, + "native_api_tested": false + }, + "privacy": { + "provider_content_persisted": false, + "provider_identities_in_evidence": false, + "grants_changed": false, + "production_host_allowlist_changed": false, + "provider_writes": false + }, + "limitations": [ + "One document proves account capability, not complete Docs capture", + "No Sheets native API request or scope proof", + "No durable structured representations, indexing, widgets or hosted authorization verified" + ] +} diff --git a/backend/tests/live/evidence/slack-lifecycle-backoff-incomplete-20260930.json b/backend/tests/live/evidence/slack-lifecycle-backoff-incomplete-20260930.json new file mode 100644 index 0000000000..438eee1329 --- /dev/null +++ b/backend/tests/live/evidence/slack-lifecycle-backoff-incomplete-20260930.json @@ -0,0 +1,178 @@ +{ + "provider": "slack", + "date": "2026-09-30", + "scope": "full accessible history without hidden filter", + "limits": { + "requests": 600, + "observations": 10000, + "seconds": 1800 + }, + "attempt": { + "provider_requests": 74, + "observations": 328, + "started_scopes": 2, + "completed_scopes": 0, + "job_status": "cancelled", + "reported_error_type": "AttributeError", + "checkpoint_unchanged": true, + "rate_limits": [ + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 12 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 14 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 17 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 19 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 21 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 23 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 25 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 27 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 30 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 33 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 35 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 37 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 39 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 41 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 43 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 45 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 47 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 49 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 51 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 54 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 56 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 58 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 60 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 62 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 64 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 66 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 68 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 70 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 72 + }, + { + "status": 429, + "retry_after_seconds": 60.0, + "request_number": 74 + } + ] + }, + "diagnosis": "Deadline cancellation transitioned job to cancelled; fixture lacked connection metadata accessed by cancellation telemetry, masking timeout with AttributeError. Fixture corrected after run; no rerun.", + "free_disk_bytes_before": 9485815808, + "second_process_started": false, + "schema_removed": true, + "blob_directory_removed": true, + "full_scope_verified": false, + "provider_writes": false +} diff --git a/backend/tests/live/evidence/slack-lifecycle-incomplete-20260930.json b/backend/tests/live/evidence/slack-lifecycle-incomplete-20260930.json new file mode 100644 index 0000000000..98640a12e7 --- /dev/null +++ b/backend/tests/live/evidence/slack-lifecycle-incomplete-20260930.json @@ -0,0 +1,25 @@ +{ + "provider": "slack", + "date": "2026-09-30", + "scope": "full accessible history without hidden filter", + "limits": { + "requests": 150, + "observations": 5000, + "seconds": 300 + }, + "attempt": { + "provider_requests": 70, + "observations": 457, + "started_scopes": 2, + "completed_scopes": 0, + "job_status": "failed", + "safe_reason": "rate_limit", + "checkpoint_unchanged": true + }, + "second_process_started": false, + "schema_removed": true, + "blob_directory_removed": true, + "full_scope_verified": false, + "retry_after_recorded": false, + "provider_writes": false +} diff --git a/backend/tests/live/evidence/wispr-lifecycle-incomplete-20260930.json b/backend/tests/live/evidence/wispr-lifecycle-incomplete-20260930.json new file mode 100644 index 0000000000..f4507c5685 --- /dev/null +++ b/backend/tests/live/evidence/wispr-lifecycle-incomplete-20260930.json @@ -0,0 +1,51 @@ +{ + "date": "2026-09-30", + "scope": "existing exposed meeting/text enumeration; no added filter", + "limits": { + "actual_http_requests": 150, + "records": 1000, + "seconds": 300 + }, + "first_attempt": { + "failed": true, + "error_type": "ValueError", + "provider_requests": 18, + "records_observed": 9, + "started": 0, + "completed": 0, + "checkpoint_unchanged": true, + "job_status": "failed" + }, + "fresh_process_repeat": false, + "scope_completion_claimed": false, + "checkpoint_support": false, + "deletion_guarantee": false, + "provider_writes": false, + "cleanup": { + "schema_removed": true, + "blob_directory_removed": true + }, + "limitation": "Initial safe diagnostics did not identify the fixed source error; a separate diagnostic attempt is required. All captured Wispr originals remain partial.", + "diagnostic_retry": { + "provider_requests": 18, + "observations": 9, + "safe_reason": "Wispr tool execution failed; capture is incomplete", + "job_status": "failed", + "started_scopes": 0, + "completed_scopes": 0, + "checkpoint_unchanged": true, + "schema_removed": true, + "blob_directory_removed": true + }, + "read_only_targeted_diagnostic": { + "requests": 18, + "operation": "get_meeting", + "error_shape": "string", + "structured_status_or_code": false, + "recognized_categories": [ + "failed" + ], + "source_parser_failure": false, + "cause_attribution": "Upstream execution boundary reported an opaque tool error; root provider versus intermediary cause is unknown." + } +} diff --git a/backend/tests/live/evidence/wispr-recovery-20260930.json b/backend/tests/live/evidence/wispr-recovery-20260930.json new file mode 100644 index 0000000000..3821498f04 --- /dev/null +++ b/backend/tests/live/evidence/wispr-recovery-20260930.json @@ -0,0 +1,42 @@ +{ + "date": "2026-09-30", + "commit": "2318901", + "provider": "wispr", + "method": "read-only real provider, actual orchestrator/pipeline and disposable PostgreSQL; intentional process loss then fresh same-job writer attempt", + "request_limit": 20, + "timeout_seconds": 180, + "runs": [ + { + "stage": "interrupt", + "failed": false, + "provider_requests": 10, + "records_observed": 256, + "completed_bodies": 3, + "intentional_interruption": true, + "full_scope_completed": false + }, + { + "stage": "resume", + "failed": false, + "provider_requests": 8, + "records_observed": 254, + "completed_bodies": 4, + "wispr_recovery_verified": true, + "full_scope_completed": false + } + ], + "aggregate_provider_requests": 18, + "aggregate_records_observed": 510, + "wispr_recovery_verified": true, + "schema_removed": true, + "independent_live_schema_count": 0, + "blob_directory_removed": true, + "proof": "Three completed body scopes remain at the same cycle/revisions; the fresh process rejects revisiting their native identities and commits one new body.", + "limitations": [ + "No complete-source traversal or subsequent-cycle proof.", + "No exhaustive discovery or deletion guarantee.", + "Failed individual meetings may repeat their text ranges.", + "Captured bodies omit provider raw editor data and remain partial.", + "No hosted auth or Almanac owner-binding attestation." + ] +} diff --git a/backend/tests/live/evidence/wispr-recovery-budget-incomplete-20260930.json b/backend/tests/live/evidence/wispr-recovery-budget-incomplete-20260930.json new file mode 100644 index 0000000000..c1fe90cd4f --- /dev/null +++ b/backend/tests/live/evidence/wispr-recovery-budget-incomplete-20260930.json @@ -0,0 +1,42 @@ +{ + "date": "2026-09-30", + "commit": "cf14b4e", + "provider": "wispr", + "method": "real provider reads through actual pipeline, intentional process interruption; disposable PostgreSQL and files", + "earlier_preflight_requests": 6, + "trial_request_limit": 14, + "aggregate_trial_requests": 14, + "aggregate_batch_requests": 20, + "runs": [ + { + "stage": "interrupt", + "provider_requests": 12, + "records_observed": 256, + "completed_meeting_bodies": 3, + "intentional_interruption": true, + "full_scope_completed": false + }, + { + "stage": "resume", + "provider_requests": 2, + "records_observed": 0, + "failed": true, + "error_type": "BudgetExceeded", + "safe_reason": "provider_requests", + "last_operation": "session", + "last_http_status": 201, + "checkpoint_unchanged": true, + "job_status": "running", + "full_scope_completed": false + } + ], + "wispr_recovery_verified": false, + "schema_removed": true, + "independent_live_schema_count": 0, + "blob_directory_removed": true, + "limitations": [ + "Resume budget expired during setup before a new body could be read.", + "No complete discovery, deletion detection or full-sync proof.", + "No hosted authentication or product owner attestation." + ] +} diff --git a/backend/tests/live/gmail_resume.py b/backend/tests/live/gmail_resume.py new file mode 100644 index 0000000000..d643f9f576 --- /dev/null +++ b/backend/tests/live/gmail_resume.py @@ -0,0 +1,105 @@ +"""Test-only process loss after an acknowledged Gmail page; never copies provider state.""" + +import json +import os + +from capture_resume import ResumeTarget +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle +from airweave.models.capture_scan import CaptureScan +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob + + +class GmailResumeProbe: + """Interrupt at the next page boundary; verify one further commit after a fresh process.""" + + def __init__( + self, sessions, organization_id, sync_id, job_id, attempt_id, target_path, mode, counters + ): + self.sessions = sessions + self.organization_id, self.sync_id = organization_id, sync_id + self.job_id, self.attempt_id = job_id, attempt_id + self.target_path, self.mode, self.counters = target_path, mode, counters + self.target = ( + ResumeTarget.model_validate_json(target_path.read_text()) if mode == "resume" else None + ) + self.resumed_saved_page = False + self.saved_cursor_expired = False + + async def before_page(self, scope, continuation): + if scope.record_type != "message" or scope.parent is not None: + raise AssertionError("Gmail recovery requires the independent message scope") + async with self.sessions() as db: + row = await db.scalar( + select(CaptureScan).where( + CaptureScan.organization_id == self.organization_id, + CaptureScan.sync_id == self.sync_id, + CaptureScan.record_type == "message", + CaptureScan.container_id.is_(None), + CaptureScan.parent_record_id.is_(None), + ) + ) + cursor = await db.scalar( + select(SyncCursor).where( + SyncCursor.organization_id == self.organization_id, + SyncCursor.sync_id == self.sync_id, + ) + ) + sync, job = await db.get(Sync, self.sync_id), await db.get(SyncJob, self.job_id) + assert row is not None and cursor is not None and sync is not None and job is not None + cycle = CaptureCycle.model_validate(cursor.cursor_data["canonical_cycle"]) + assert sync.organization_id == job.organization_id == self.organization_id + assert job.sync_id == self.sync_id and job.status == "running" + assert sync.writer_job_id == self.job_id and sync.writer_attempt_id == self.attempt_id + assert cycle.phase == "active" and cycle.mode == "full" + assert cycle.starting_checkpoint is not None + assert row.cycle_id == cycle.version.cycle_id and row.phase == "collecting" + assert row.continuation == continuation.value + target = ResumeTarget( + scan_id=row.id, cycle_id=row.cycle_id, sweep_id=row.sweep_id, revision=row.revision + ) + if self.mode == "interrupt": + if not self.counters["records_observed"]: + return + self.target_path.write_text(target.model_dump_json()) + os.chmod(self.target_path, 0o600) + self._stop(False) + assert self.counters["capture_profile_requests"] == 0, ( + "Resume fetched another starting boundary" + ) + if target == self.target: + self.resumed_saved_page = True + elif self.resumed_saved_page and not self.saved_cursor_expired: + assert (target.scan_id, target.cycle_id, target.sweep_id) == ( + self.target.scan_id, + self.target.cycle_id, + self.target.sweep_id, + ) + assert target.revision > self.target.revision + self._stop(True) + + def cursor_expired(self): + self.saved_cursor_expired = True + + def _stop(self, verified): + print( + json.dumps( + { + "failed": False, + "counters_complete": True, + "intentional_interruption": not verified, + "gmail_recovery_verified": verified, + "resumed_saved_page": self.resumed_saved_page, + "saved_cursor_expired": self.saved_cursor_expired, + "full_scope_completed": False, + "checkpoint_saved": False, + "job_status": "running", + **self.counters, + } + ), + flush=True, + ) + os._exit(76 if verified else 75) diff --git a/backend/tests/live/provider_lifecycle.py b/backend/tests/live/provider_lifecycle.py new file mode 100644 index 0000000000..9611bd62bf --- /dev/null +++ b/backend/tests/live/provider_lifecycle.py @@ -0,0 +1,1070 @@ +"""Opt-in Gmail, selected Calendar, or full accessible Drive lifecycle proof. + +Two fresh processes; no Temporal worker, hosted factory, auth or product binding claim. +Parent owns private schema/files cleanup on success and failure. No provider writes. +""" + +import asyncio +import hashlib +import json +import logging +import os +import re +import shutil +import sys +import time +from contextlib import aclosing, asynccontextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from tempfile import TemporaryDirectory, gettempdir +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import UUID, uuid4 + +import canonical_capture as harness # settings must precede application imports +from calendar_lifecycle import count_calendar_request, event_checkpoint, verify_calendar_scopes +from capture_comparison import compare_observations +from provider_sample import rest_source, wispr_source +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from airweave.adapters.event_bus.fake import FakeEventBus +from airweave.domains.entities.canonical.page_source import ( + CanonicalPageSource, + CheckpointedPageSource, + InvalidCaptureCheckpoint, + InvalidScanContinuation, + KnownObjectSource, + ScopedPageSource, +) +from airweave.domains.entities.canonical.requests import CaptureRecord, CompletedScope, StartedScope +from airweave.domains.entities.canonical.service import CanonicalCaptureService +from airweave.domains.entities.canonical.store import CanonicalRecordStore, source_record +from airweave.domains.storage.file_service import FileService +from airweave.domains.sync_pipeline.canonical_capture import CanonicalCapturePipeline +from airweave.domains.sync_pipeline.capture_attempt import CaptureAttempt +from airweave.domains.sync_pipeline.config import SyncConfig +from airweave.domains.sync_pipeline.contexts.runtime import SyncRuntime +from airweave.domains.sync_pipeline.orchestrator import SyncOrchestrator +from airweave.domains.sync_pipeline.pipeline.entity_tracker import EntityTracker +from airweave.domains.sync_pipeline.stream import AsyncSourceStream +from airweave.domains.sync_pipeline.worker_pool import AsyncWorkerPool +from airweave.domains.syncs.cursors.cursor import SyncCursor +from airweave.domains.syncs.cursors.service import SyncCursorService +from airweave.domains.syncs.jobs.repository import SyncJobRepository +from airweave.domains.syncs.jobs.state_machine import SyncJobStateMachine +from airweave.models import Entity, Organization, Sync, SyncJob +from airweave.models import SyncCursor as StoredCursor +from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.cursors.gmail import GmailCursor +from airweave.platform.cursors.google_calendar import GoogleCalendarCursor +from airweave.platform.cursors.google_drive import GoogleDriveCursor +from airweave.platform.http_client.composio_transport import ComposioProxyError +from airweave.platform.sources.slack import SlackApiError + + +class BudgetExceeded(RuntimeError): + """An explicit live-test bound was exceeded; source must not finish.""" + + +class BoundedStorage(harness.FilesystemBackend): + """Cap all writes, even repeated MIME reads, before retaining another blob.""" + + def __init__(self, root, byte_limit, counters=None): + super().__init__(root) + self.written_bytes = 0 + self.byte_limit = byte_limit + self.counters = counters + + async def write_file(self, path, content): + if self.written_bytes + len(content) > self.byte_limit: + raise BudgetExceeded("blob_bytes") + await super().write_file(path, content) + self.written_bytes += len(content) + if self.counters is not None: + self.counters["blob_bytes_written"] = self.written_bytes + + +class BoundedPageSource: + """Test budget around the real page adapter; production driver still owns all progress.""" + + def __init__(self, source, counters, record_limit, resume_probe=None): + self.source, self.counters, self.record_limit = source, counters, record_limit + self.resume_probe = resume_probe + self.canonical_record_types = source.canonical_record_types + self.canonical_container_parents = source.canonical_container_parents + self.capture_cycle_configuration = source.capture_cycle_configuration + + async def capture_page(self, scope, continuation, *, files: FileService, parent=None): + if self.resume_probe is not None: + await self.resume_probe.before_page(scope, continuation) + try: + page = await self.source.capture_page(scope, continuation, files=files, parent=parent) + except (InvalidScanContinuation, InvalidCaptureCheckpoint): + if self.resume_probe is not None: + self.resume_probe.cursor_expired() + raise + self.counters["records_observed"] += len(page.records) + len(page.discovered_records) + if self.counters["records_observed"] > self.record_limit: + raise BudgetExceeded("records") + return page + + def child_scope(self, parent, record_type): + return self.source.child_scope(parent, record_type) + + async def confirm_absent(self, record): + await self.source.confirm_absent(record) + + +class BoundedKnownSource(BoundedPageSource): + async def refresh_known(self, record, *, files): + observation = await self.source.refresh_known(record, files=files) + self.counters["records_observed"] += 1 + if self.counters["records_observed"] > self.record_limit: + raise BudgetExceeded("records") + return observation + + +class BoundedCheckpointSource(BoundedPageSource): + async def prepare_cycle(self, previous): + return await self.source.prepare_cycle(previous) + + def initial_continuation(self, cycle): + return self.source.initial_continuation(cycle) + + +class BoundedCheckpointKnownSource(BoundedCheckpointSource, BoundedKnownSource): + pass + + +class BoundedScopedSource(BoundedPageSource): + async def prepare_cycle(self, previous): + return await self.source.prepare_cycle(previous) + + async def prepare_scope(self, scope, cycle, previous, *, parent, force_full): + return await self.source.prepare_scope( + scope, cycle, previous, parent=parent, force_full=force_full + ) + + def initial_scope_continuation(self, scope, cycle, plan): + return self.source.initial_scope_continuation(scope, cycle, plan) + + +class BoundedScopedKnownSource(BoundedScopedSource, BoundedKnownSource): + pass + + +def bounded_page_source(source, counters, record_limit, probe=None): + """Preserve only protocols the actual source implements.""" + if isinstance(source, ScopedPageSource): + wrapper = ( + BoundedScopedKnownSource + if isinstance(source, KnownObjectSource) + else BoundedScopedSource + ) + return wrapper(source, counters, record_limit, probe) + checkpoint = isinstance(source, CheckpointedPageSource) + known = isinstance(source, KnownObjectSource) + wrapper = ( + (BoundedCheckpointKnownSource if known else BoundedCheckpointSource) + if checkpoint + else (BoundedKnownSource if known else BoundedPageSource) + ) + return wrapper(source, counters, record_limit, probe) + + +async def bounded_observations(source, cursor, files, counters, record_limit): + """A breached test budget fails enumeration before scope completion.""" + async with aclosing(source.generate_observations(cursor=cursor, files=files)) as stream: + async for item in stream: + if isinstance(item, CaptureRecord): + counters["records_observed"] += 1 + if counters["records_observed"] > record_limit: + raise BudgetExceeded("records") + elif isinstance(item, StartedScope): + counters["started"] += 1 + elif isinstance(item, CompletedScope): + counters["completed"] += 1 + yield item + + +def safe_failure_reason(error): + """Only local fixed diagnostics; never emit provider payloads, URLs or IDs.""" + text = str(error) + if isinstance(error, BudgetExceeded) and text in { + "records", + "provider_requests", + "blob_bytes", + "rate_limit", + }: + return text + if type(error) is ValueError and text in { + "Wispr tool execution failed; capture is incomplete", + "Wispr returned incomplete or repeated pagination", + "Wispr query cap cannot be partitioned safely", + "Wispr date partition made no progress", + "Wispr returned a meeting without identity", + "Wispr meeting text is not a string", + "Wispr returned a different meeting identity", + "Wispr meeting changed during paginated capture", + "Wispr continuation made no progress", + "Wispr meeting exceeds the bounded capture page limit", + }: + return text + if isinstance(error, SlackApiError) and error.code in { + "missing_scope", + "not_in_channel", + "channel_not_found", + "is_archived", + "method_not_supported_for_channel_type", + "not_allowed_token_type", + }: + return f"Slack request failed: {error.code}" + if isinstance(error, ComposioProxyError) and ( + text + in { + "Proxy download exceeds the file size limit", + "Composio response exceeds the file size limit", + "Invalid Composio response envelope", + "Temporary download request failed", + } + or re.fullmatch(r"(?:Composio proxy|Temporary download) returned HTTP [0-9]{3}", text) + ): + return text + return None + + +def validate_checkpoint(name, manifest, counters, saved, previous, loaded, attempt, sequence): + """Match production cursor/scope capabilities; never manufacture a resume claim.""" + if name == "google_calendar": + cycle = saved["canonical_cycle"] + assert cycle["phase"] == "complete" and cycle["mode"] == "mixed" + assert cycle["completed_job_id"] + assert cycle["last_full_capture"] is None and cycle["promoted_checkpoint"] is None + assert counters["started"] == counters["completed"] == 0 + assert bool(counters["sync_token_requests"]) == loaded + elif name == "google_drive": + cycle = saved["canonical_cycle"] + assert cycle["phase"] == "complete" and cycle["completed_job_id"] + assert cycle["last_full_capture"] and cycle["promoted_checkpoint"] + assert cycle["promoted_checkpoint"]["checkpoint"]["value"]["page_token"] + previous_cycle = previous.get("canonical_cycle", {}) + changes_expected = loaded and ( + previous_cycle.get("phase") == "complete" or previous_cycle.get("mode") == "changes" + ) + # A durable cursor can also be an unfinished full enumeration. Loading it + # resumes full capture; it is not evidence that a changes pass began. + assert cycle["mode"] == ("changes" if changes_expected else "full") + assert counters["started"] == counters["completed"] == 0 + assert bool(counters["resumed_changes_requests"]) == changes_expected + elif name in {"slack", "wispr"}: + assert saved["canonical_cycle"]["phase"] == "complete" + assert saved["canonical_cycle"]["completed_job_id"] + else: + cycle = saved["canonical_cycle"] + assert cycle["phase"] == "complete" and cycle["completed_job_id"] + if manifest.get("gmail_unfiltered"): + assert cycle["promoted_checkpoint"] and cycle["last_full_capture"] + if previous.get("canonical_cycle", {}).get("phase") == "complete": + assert cycle["mode"] == "changes" + assert counters["resumed_changes_requests"] > 0 + assert counters["capture_profile_requests"] == 0 + else: + assert cycle["mode"] == "full" and cycle["promoted_checkpoint"] is None + assert saved["canonical_checkpoint"] == { + "writer_attempt_id": str(attempt.id), + "observed_change_sequence": sequence, + } + assert saved != previous + + +async def prepare_job(db, organization_id, sync_id, job_id, attempt_number): + """Only an explicit retry may reuse a still-running job in the private schema.""" + if attempt_number == 1: + job = SyncJob(id=job_id, organization_id=organization_id, sync_id=sync_id, status="pending") + db.add(job) + await db.commit() + return job + job = await db.get(SyncJob, job_id) + if ( + job is None + or job.sync_id != sync_id + or job.organization_id != organization_id + or job.status != "running" + ): + raise ValueError("Resume requires the same interrupted running job") + return job + + +def prepare_resume_probe(manifest, sessions, organization_id, sync_id, job_id, attempt, counters): + """Fault injection is opt-in for the two explicit cross-process trial stages.""" + if manifest.get("resume_stage") not in {"interrupt", "resume"}: + return None + from capture_resume import PageResumeProbe + from gmail_resume import GmailResumeProbe + from wispr_resume import WisprResumeProbe + + probe_type = ( + GmailResumeProbe + if manifest.get("gmail_resume") + else (WisprResumeProbe if manifest.get("wispr_resume") else PageResumeProbe) + ) + return probe_type( + sessions, + organization_id, + sync_id, + job_id, + attempt.id, + Path(manifest["root"]) / "resume-target.json", + manifest["resume_stage"], + counters, + ) + + +def count_gmail_request(name, request, previous, counters, identity_verified): + """Count required identity verification separately from production capture planning.""" + if name != "gmail": + return + if request.url.path.endswith("/profile"): + key = "capture_profile_requests" if identity_verified else "identity_profile_requests" + counters[key] += 1 + if request.url.path.endswith("/history"): + boundary = previous.get("canonical_cycle", {}).get("promoted_checkpoint") + if ( + boundary + and request.url.params.get("startHistoryId") + == boundary["checkpoint"]["value"]["history_id"] + ): + counters["resumed_changes_requests"] += 1 + + +def count_drive_request(name, request, previous, counters): + """Count only an actual request using the previously published Drive boundary.""" + if name != "google_drive" or not request.url.path.endswith("/changes"): + return + promoted = previous.get("canonical_cycle", {}).get("promoted_checkpoint") + if ( + promoted + and request.url.params.get("pageToken") == promoted["checkpoint"]["value"]["page_token"] + ): + counters["resumed_changes_requests"] += 1 + + +async def child(manifest): + engine = create_async_engine( + harness.test_database_url(), + connect_args={"server_settings": {"search_path": manifest["schema"]}}, + ) + sessions = async_sessionmaker(engine, expire_on_commit=False) + organization_id, sync_id = UUID(manifest["organization_id"]), UUID(manifest["sync_id"]) + job_id = UUID(manifest["job_id"]) if "job_id" in manifest else uuid4() + attempt_number = manifest.get("attempt_number", 1) + counters = {"provider_requests": 0, "records_observed": 0, "started": 0, "completed": 0} + result = {"failed": True} + previous = {} + previous_calendar_checkpoint = None + name = manifest["provider"] + is_calendar = name == "google_calendar" + counters["sync_token_requests"] = 0 + counters["resumed_changes_requests"] = 0 + counters["identity_profile_requests"] = 0 + counters["capture_profile_requests"] = 0 + counters["blob_bytes_written"] = 0 + identity_verified = False + from functools import partial + + from wispr_diagnostics import WisprDiagnostics, observe_request + + wispr_diagnostics = WisprDiagnostics() + last_operation = "identity" + rate_limits = [] + resume_probe = None + + async def observe_slack_rate_limit(response): + await response.aread() + limited = response.status_code == 429 or response.json().get("error") in { + "ratelimited", + "rate_limited", + } + if limited: + from airweave.platform.sources.http_helpers import _parse_retry_after + + rate_limits.append( + { + "status": response.status_code, + "retry_after_seconds": _parse_retry_after(response, default=60), + "request_number": counters["provider_requests"], + } + ) + + async def request_hook(request): + nonlocal last_operation + last_operation = ( + "export" + if request.url.path.endswith("/export") + else ("download" if request.url.params.get("alt") == "media" else "metadata") + ) + if counters["provider_requests"] >= manifest["request_limit"]: + raise BudgetExceeded("provider_requests") + counters["provider_requests"] += 1 + count_gmail_request(name, request, previous, counters, identity_verified) + if is_calendar: + count_calendar_request( + request, + manifest["calendar_config"]["calendar_ids"][0], + previous_calendar_checkpoint, + counters, + ) + count_drive_request(name, request, previous, counters) + + @asynccontextmanager + async def db_context(): + async with sessions() as db: + yield db + + try: + async with sessions() as db: + organization = await db.get(Organization, organization_id) + sync = await db.get(Sync, sync_id) + job = await prepare_job(db, organization_id, sync_id, job_id, attempt_number) + before = { + row.id: source_record(row) + for row in await db.scalars(select(Entity).where(Entity.sync_id == sync_id)) + if row.record_revision > 0 + } + + config = SyncConfig() + config.behavior.skip_guardrails = True + logger = logging.getLogger("private-lifecycle") + ctx = SimpleNamespace( + organization_id=organization_id, + sync_id=sync_id, + sync_job_id=job_id, + organization=organization, + sync=sync, + sync_job=job, + logger=logger, + collection_id=uuid4(), + source_connection_id=uuid4(), + source_short_name=name, + connection=SimpleNamespace(id=uuid4(), short_name=name), + execution_config=config, + force_full_sync=False, + batch_size=10, + max_batch_latency_ms=20, + should_batch=True, + has_user_context=False, + tracking_email=None, + ) + cursor_service = SyncCursorService() + async with sessions() as db: + previous = await cursor_service.get_cursor_data(db, sync_id, ctx) + if is_calendar: + previous_calendar_checkpoint = await event_checkpoint( + db, organization_id, sync_id, manifest["calendar_config"]["calendar_ids"][0] + ) + cursor_schema = { + "gmail": GmailCursor, + "google_calendar": GoogleCalendarCursor, + "google_drive": GoogleDriveCursor, + }.get(name) + cursor = SyncCursor(sync_id, cursor_schema, previous or None) if cursor_schema else None + loaded = cursor.loaded_from_db if cursor else False + storage = BoundedStorage( + Path(manifest["root"]) / "blobs", manifest["blob_byte_limit"], counters + ) + files = FileService(job_id, storage, sync_id=sync_id) + files.MAX_FILE_SIZE_BYTES = manifest["file_byte_limit"] + fence = SimpleNamespace(organization_id=organization_id, sync_id=sync_id, job_id=job_id) + key = os.environ["COMPOSIO_API_KEY"] + account_variable = { + "gmail": "LIVE_GMAIL_ACCOUNT_ID", + "google_calendar": "LIVE_CALENDAR_ACCOUNT_ID", + "google_drive": "LIVE_DRIVE_ACCOUNT_ID", + "slack": "LIVE_SLACK_ACCOUNT_ID", + "wispr": "LIVE_WISPR_ACCOUNT_ID", + }[name] + connection = ( + wispr_source( + os.environ[account_variable], + key, + fence, + request_hook=partial(observe_request, wispr_diagnostics, request_hook), + response_hook=wispr_diagnostics.response, + envelope_hook=wispr_diagnostics.envelope, + ) + if name == "wispr" + else rest_source( + name, + os.environ[account_variable], + os.environ["LIVE_EXPECTED_EMAIL"], + key, + fence, + gmail_query=manifest.get("query", ""), + gmail_unfiltered=manifest.get("gmail_unfiltered", False), + calendar_config=GoogleCalendarConfig.model_validate(manifest["calendar_config"]) + if is_calendar + else None, + request_hook=request_hook, + response_hook=observe_slack_rate_limit if name == "slack" else None, + max_file_bytes=manifest["file_byte_limit"], + ) + ) + async with ( + asyncio.timeout(manifest["timeout"]), + connection as (source, identity_verification), + ): + identity_verified = True + assert source.cursor_class == cursor_schema + bus = FakeEventBus() + attempt = CaptureAttempt(id=uuid4(), number=attempt_number) + resume_probe = prepare_resume_probe( + manifest, sessions, organization_id, sync_id, job_id, attempt, counters + ) + page_source = ( + bounded_page_source(source, counters, manifest["record_limit"], resume_probe) + if isinstance(source, CanonicalPageSource) + else None + ) + pipeline = CanonicalCapturePipeline( + CanonicalCaptureService(CanonicalRecordStore()), + sessions, + bus, + source.canonical_record_types, + attempt, + container_parents=getattr(source, "canonical_container_parents", {}), + page_source=page_source, + files=files, + ) + runtime = SyncRuntime( + source=source, + entity_tracker=EntityTracker(job_id, sync_id, logger), + cursor=cursor, + canonical_capture=pipeline, + ) + runner = SyncOrchestrator( + entity_pipeline=pipeline, + worker_pool=AsyncWorkerPool(logger=logger), + stream=( + None + if page_source is not None + else AsyncSourceStream( + bounded_observations( + source, cursor, files, counters, manifest["record_limit"] + ), + logger=logger, + ) + ), + sync_context=ctx, + runtime=runtime, + access_control_pipeline=AsyncMock(), + event_bus=bus, + usage_checker=AsyncMock(), + usage_ledger=AsyncMock(), + sync_cursor_service=cursor_service, + state_machine=SyncJobStateMachine(SyncJobRepository(), bus), + lifecycle_data=None, + sync_state_machine=AsyncMock(), + ) + # Only connection ownership and external telemetry are replaced; state + # transitions, capture, reconciliation and cursor writes are production code. + with ( + patch("airweave.domains.syncs.jobs.state_machine.get_db_context", db_context), + patch("airweave.domains.sync_pipeline.orchestrator.business_events"), + ): + await runner.run() + await engine.dispose() + async with sessions() as db: + saved = await db.scalar( + select(StoredCursor.cursor_data).where(StoredCursor.sync_id == sync_id) + ) + status = await db.scalar(select(SyncJob.status).where(SyncJob.id == job_id)) + sequence = await db.scalar( + select(Sync.observed_change_sequence).where(Sync.id == sync_id) + ) + rows = list((await db.scalars(select(Entity).where(Entity.sync_id == sync_id))).all()) + assert status == "completed" + validate_checkpoint(name, manifest, counters, saved, previous, loaded, attempt, sequence) + await verify_calendar_scopes(sessions, organization_id, sync_id, manifest, saved, loaded) + visible = [r for r in rows if r.deleted_at is None and r.source_payload is not None] + digest = hashlib.sha256( + json.dumps( + sorted( + ( + r.entity_definition_short_name, + r.container_id or "", + r.native_id, + r.record_revision, + r.source_payload, + ) + for r in visible + ), + sort_keys=True, + default=str, + ).encode() + ).hexdigest() + consumer_result = None + if is_calendar and loaded and os.environ.get("LIVE_VERIFY_CALENDAR_CONSUMER") == "1": + from almanac_handoff import verify_calendar_reader + + consumer_result = await verify_calendar_reader( + sessions=sessions, + organization_id=organization_id, + sync_id=sync_id, + root=Path(manifest["root"]), + storage=storage, + window=manifest["calendar_config"]["occurrence_window"], + calendar_id=manifest["calendar_config"]["calendar_ids"][0], + ) + result = { + "failed": False, + "full_scope_completed": name != "wispr" + and not (name == "gmail" and saved["canonical_cycle"]["mode"] == "changes") + and not (is_calendar and loaded), + "counters_complete": True, + "resumed_saved_page": resume_probe.resumed_saved_page if resume_probe else False, + "saved_cursor_expired": resume_probe.saved_cursor_expired if resume_probe else False, + "provider": name, + "identity_verification": identity_verification, + "almanac_consumer": consumer_result, + **counters, + "rate_limits": rate_limits, + "stored_records": len(visible), + "partial_records": sum(r.completeness != "complete" for r in visible), + "blob_bytes_written": storage.written_bytes, + "checkpoint_saved": saved is not None, + "observed_change_sequence": sequence, + "loaded_durable_checkpoint": loaded, + "job_status": status, + "payload_revision_digest": digest, + "observation_comparison": compare_observations( + before, + {row.id: source_record(row) for row in rows if row.record_revision > 0}, + name, + ), + "mode": { + "gmail": ("unfiltered_" + saved["canonical_cycle"]["mode"]) + if manifest.get("gmail_unfiltered") + else "filtered_full_reconciliation", + "google_calendar": "calendar_incremental" if loaded else "calendar_initial", + "google_drive": "drive_incremental" if loaded else "drive_initial", + "slack": "accessible_history_full_reconciliation", + "wispr": "exposed_meeting_enumeration_no_deletion_guarantee", + }[name], + "all_day_occurrences": sum( + r.entity_definition_short_name == "event_occurrence" + and "date" in r.source_payload.get("start", {}) + for r in visible + ), + } + except Exception as error: + async with sessions() as db: + saved = await db.scalar( + select(StoredCursor.cursor_data).where(StoredCursor.sync_id == sync_id) + ) + status = await db.scalar(select(SyncJob.status).where(SyncJob.id == job_id)) + result = { + "failed": True, + "full_scope_completed": False, + "counters_complete": True, + "resumed_saved_page": resume_probe.resumed_saved_page if resume_probe else False, + "saved_cursor_expired": resume_probe.saved_cursor_expired if resume_probe else False, + "error_type": type(error).__name__, + "safe_reason": safe_failure_reason(error), + "last_operation": wispr_diagnostics.operation if name == "wispr" else last_operation, + "wispr_diagnostics": wispr_diagnostics.model_dump() if name == "wispr" else None, + **counters, + "rate_limits": rate_limits, + "checkpoint_unchanged": saved == (previous or None), + "job_status": status, + } + finally: + await engine.dispose() + print(json.dumps(result), flush=True) + return 1 if result["failed"] else 0 + + +def check_free_disk(provider): + """Keep the explicitly approved local-disk reserve before each Drive process.""" + available = shutil.disk_usage(gettempdir()).free + if provider == "google_drive" and available < 2 * 1024**3: + raise BudgetExceeded("free_disk") + return available + + +async def execute_trial(path, timeout): + """Return only sanitized child evidence; forced termination has unknown usage.""" + process = await asyncio.create_subprocess_exec( + sys.executable, + str(Path(__file__).absolute()), + str(path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, _ = await asyncio.wait_for(process.communicate(), timeout=timeout) + except asyncio.TimeoutError: + return -9, { + "failed": True, + "safe_reason": "aggregate_deadline", + "full_scope_completed": False, + "counters_complete": False, + } + finally: + if process.returncode is None: + process.kill() + await process.wait() + lines = [json.loads(line) for line in stdout.decode().splitlines() if line.startswith("{")] + if not lines: + return process.returncode, { + "failed": True, + "safe_reason": "child_no_result", + "full_scope_completed": False, + "counters_complete": False, + } + return process.returncode, lines[-1] + + +def remaining_blob_budget(manifest, results, total): + """Repeated writes consume the same parent-owned trial allowance.""" + if total is None: + return True + manifest["blob_byte_limit"] = total - sum(item.get("blob_bytes_written", 0) for item in results) + if manifest["blob_byte_limit"] > 0: + return True + results.append( + {"failed": True, "safe_reason": "aggregate_blob_budget", "full_scope_completed": False} + ) + return False + + +def mark_early_completion(gmail_resume, stage, code, result): + if gmail_resume and stage == "interrupt" and code == 0: + result["recovery_not_exercised"] = True + + +async def run_trials(manifest, evidence_reader=None): + """One parent-owned aggregate budget across interruption, retry, and new-job proof.""" + path = Path(manifest["root"]) / "manifest.json" + results = [] + name = manifest["provider"] + wispr_resume = manifest.get("wispr_resume", False) + gmail_resume = manifest.get("gmail_resume", False) + resume_trial = name == "slack" or wispr_resume or gmail_resume + stages = ( + ( + ("interrupt", "resume") + if wispr_resume or gmail_resume + else ("interrupt", "resume", "new_cycle") + ) + if resume_trial + else ("first", "second") + ) + total_requests, total_records = manifest["request_limit"], manifest["record_limit"] + total_blob_bytes = manifest.get("blob_byte_limit") + deadline = time.monotonic() + manifest["timeout"] if resume_trial else None + interrupted_job_id = str(uuid4()) + for stage in stages: + check_free_disk(name) + if resume_trial: + remaining = deadline - time.monotonic() + requests_left = total_requests - sum( + item.get("provider_requests", 0) for item in results + ) + records_left = total_records - sum(item.get("records_observed", 0) for item in results) + if remaining <= 0 or requests_left <= 0 or records_left <= 0: + results.append( + { + "failed": True, + "safe_reason": "aggregate_budget", + "full_scope_completed": False, + } + ) + break + if not remaining_blob_budget(manifest, results, total_blob_bytes): + break + manifest.update( + resume_stage=stage, + job_id=interrupted_job_id if stage != "new_cycle" else str(uuid4()), + attempt_number=2 if stage == "resume" else 1, + request_limit=requests_left, + record_limit=records_left, + timeout=remaining, + ) + path.write_text(json.dumps(manifest)) + code, result = await execute_trial( + path, + max(0.001, deadline - time.monotonic()) if resume_trial else manifest["timeout"] + 60, + ) + if stage == "resume" and evidence_reader is not None: + result.update(await evidence_reader()) + result["resumed_partial"] = bool( + result.get("resumed_page_committed") and not result.get("full_scope_completed") + ) + mark_early_completion(gmail_resume, stage, code, result) + results.append(result) + print(json.dumps({"run": len(results), "stage": stage, **result}), flush=True) + if stage == "interrupt": + if code != 75 or not result.get("intentional_interruption"): + break + elif ( + (wispr_resume or gmail_resume) + and stage == "resume" + and code == 76 + and (result.get("wispr_recovery_verified") or result.get("gmail_recovery_verified")) + ): + break + elif code: + break + return results + + +def trial_counter_summary(results): + """A killed child has unknown usage; known values are only a lower bound.""" + complete = all(item.get("counters_complete", True) for item in results) + requests = sum(item.get("provider_requests", 0) for item in results) + records = sum(item.get("records_observed", 0) for item in results) + return { + "aggregate_counters_complete": complete, + "aggregate_provider_requests": requests if complete else None, + "aggregate_records_observed": records if complete else None, + "known_provider_requests": requests, + "known_records_observed": records, + } + + +def trial_read_summary(results): + """Report observed token reuse and revision stability independently of full scopes.""" + successful = [item for item in results if not item.get("failed", True)] + digests = [item.get("payload_revision_digest") for item in successful] + reused = any( + item.get("loaded_durable_checkpoint", False) + and not item.get("saved_cursor_expired", False) + and ( + item.get("resumed_saved_page", False) + or item.get("sync_token_requests", 0) > 0 + or item.get("resumed_changes_requests", 0) > 0 + ) + for item in successful + ) + return { + "saved_cursor_reused": reused, + "identical_second_read": len(results) == len(successful) == 2 + and bool(digests[0]) + and digests[0] == digests[1], + } + + +def wispr_request_limit(value: str) -> int: + """Validate an explicitly reduced trial budget before setup or network access.""" + try: + limit = int(value) + except ValueError: + raise ValueError("Wispr request limit must be an integer from 1 to 20") from None + if not 1 <= limit <= 20: + raise ValueError("Wispr request limit must be an integer from 1 to 20") + return limit + + +def gmail_trial_options(name): + resume = os.environ.get("LIVE_GMAIL_RESUME") == "1" + unfiltered = os.environ.get("LIVE_GMAIL_UNFILTERED") == "1" + if (resume or unfiltered) and name != "gmail": + raise ValueError("Gmail trial options require Gmail provider") + if resume and not unfiltered: + raise ValueError("Gmail recovery proof requires explicit unfiltered scope") + return resume, unfiltered + + +async def main(): + name = os.environ.get("LIVE_LIFECYCLE_PROVIDER", "gmail") + if name not in {"gmail", "google_calendar", "google_drive", "slack", "wispr"}: + raise ValueError("Unsupported lifecycle provider") + wispr_resume = os.environ.get("LIVE_WISPR_RESUME") == "1" + gmail_resume, gmail_unfiltered = gmail_trial_options(name) + if wispr_resume and name != "wispr": + raise ValueError("Wispr resume requires Wispr provider") + request_limit = ( + wispr_request_limit(os.environ.get("LIVE_WISPR_REQUEST_LIMIT", "20")) + if wispr_resume + else None + ) + free_disk_bytes = check_free_disk(name) + url = harness.test_database_url() + schema = "canonical_live_" + uuid4().hex + admin = create_async_engine(url) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": schema}}) + created = False + old_umask = os.umask(0o077) + results = [] + try: + async with admin.begin() as connection: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + created = True + async with engine.begin() as connection: + for migration in ( + "0000_baseline.py", + "0001_canonical_records.py", + "0002_projection_publication.py", + "0003_mail_thread_index.py", + "0004_projection_generation.py", + "0005_capture_scan.py", + "0006_record_visibility.py", + "0007_scan_scope_owner.py", + "0008_scope_execution.py", + "0009_extraction_coverage.py", + "0010_owned_provisioning.py", + ): + await connection.run_sync(harness.migrate, migration) + organization_id, sync_id = uuid4(), uuid4() + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as db: + db.add(Organization(id=organization_id, name="Private provider lifecycle proof")) + await db.flush() + db.add( + Sync( + id=sync_id, + organization_id=organization_id, + name="Fixed selected provider scope", + ) + ) + await db.commit() + with TemporaryDirectory(prefix="airweave-private-live-") as directory: + root = Path(directory) + end = int(time.time()) + manifest = { + "provider": name, + "request_limit": { + "gmail": 600, + "google_calendar": 100, + "google_drive": 250, + "slack": 600, + "wispr": 150, + }[name], + "record_limit": { + "gmail": 250, + "google_calendar": 10000, + "google_drive": 2000, + "slack": 10000, + "wispr": 1000, + }[name], + "timeout": { + "gmail": 600, + "google_calendar": 180, + "google_drive": 300, + "slack": 7200, + "wispr": 300, + }[name], + "blob_byte_limit": (512 if name == "google_drive" else 256) * 1024 * 1024, + "file_byte_limit": FileService.MAX_FILE_SIZE_BYTES + if name == "google_drive" + else 10 * 1024 * 1024, + "schema": schema, + "root": str(root), + "organization_id": str(organization_id), + "sync_id": str(sync_id), + "query": f"after:{end - 7 * 86400} before:{end}", + } + if name == "gmail": + manifest.update(gmail_resume=gmail_resume, gmail_unfiltered=gmail_unfiltered) + if wispr_resume: + manifest.update( + wispr_resume=True, request_limit=request_limit, record_limit=600, timeout=180 + ) + if name == "google_calendar": + now = datetime.now(timezone.utc).replace(microsecond=0) + manifest["calendar_config"] = { + "calendar_ids": [os.environ["LIVE_EXPECTED_EMAIL"]], + "occurrence_window": { + "start": now.isoformat(), + "end": (now + timedelta(days=7)).isoformat(), + }, + } + + async def resume_evidence(): + from capture_resume import inspect_resume_progress + + return await inspect_resume_progress( + sessions, + organization_id, + sync_id, + UUID(manifest["job_id"]), + root / "resume-target.json", + ) + + results = await run_trials( + manifest, resume_evidence if name == "slack" or gmail_resume else None + ) + + finally: + os.umask(old_umask) + await engine.dispose() + if created: + async with admin.begin() as connection: + await connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + await admin.dispose() + resume_verified = ( + name == "slack" + and len(results) == 3 + and results[0].get("intentional_interruption") + and results[1].get("resumed_page_committed") + and results[1].get("full_scope_completed") + and results[2].get("full_scope_completed") + ) + gmail_verified = bool( + gmail_resume + and len(results) == 2 + and results[0].get("intentional_interruption") + and results[1].get("resumed_page_committed") + and results[1].get("resumed_saved_page") + and results[1].get("capture_profile_requests") == 0 + and not results[1].get("saved_cursor_expired") + ) + print( + json.dumps( + { + "runs": len(results), + "free_disk_bytes_before": free_disk_bytes, + "schema_removed": True, + "blob_directory_removed": True, + "resume_verified": bool(resume_verified), + "gmail_recovery_verified": gmail_verified, + "wispr_recovery_verified": bool( + wispr_resume + and len(results) == 2 + and results[1].get("wispr_recovery_verified", False) + ), + "resumed_page_committed": any( + item.get("resumed_page_committed", False) for item in results + ), + "resumed_partial": any(item.get("resumed_partial", False) for item in results), + **trial_counter_summary(results), + **trial_read_summary(results), + } + ) + ) + wispr_verified = ( + wispr_resume and len(results) == 2 and results[1].get("wispr_recovery_verified", False) + ) + success = ( + gmail_verified + if gmail_resume + else wispr_verified + if wispr_resume + else (resume_verified if name == "slack" else len(results) == 2) + ) + return 0 if success and not any(item["failed"] for item in results) else 1 + + +if __name__ == "__main__": + try: + if len(sys.argv) == 2: + manifest = json.loads(Path(sys.argv[1]).read_text()) + harness.StoragePaths.TEMP_PROCESSING = str(Path(manifest["root"]) / "downloads") + code = asyncio.run(child(manifest)) + else: + code = asyncio.run(main()) + except Exception as error: + print(json.dumps({"failed": True, "error_type": type(error).__name__}), flush=True) + code = 1 + raise SystemExit(code) diff --git a/backend/tests/live/provider_sample.py b/backend/tests/live/provider_sample.py new file mode 100644 index 0000000000..baf0417ab1 --- /dev/null +++ b/backend/tests/live/provider_sample.py @@ -0,0 +1,185 @@ +"""Account-checked source construction for the bounded live capture harness.""" + +import logging +import os +from contextlib import asynccontextmanager +from urllib.parse import quote + +import httpx + +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + ManagedToolAuthProvider, +) +from airweave.platform.configs.config import ( + GmailConfig, + GoogleCalendarConfig, + GoogleDriveConfig, + SlackConfig, +) +from airweave.platform.http_client.airweave_client import AirweaveHttpClient +from airweave.platform.http_client.composio_transport import ComposioTransport +from airweave.platform.sources import GmailSource, GoogleDriveSource +from airweave.platform.sources.google_calendar import GoogleCalendarSource +from airweave.platform.sources.slack import SlackSource +from airweave.platform.sources.wispr import WisprSource + + +async def verify_rest_identity(name, source, expected_email): + """Use actual provider identity; names/connection labels are not identity.""" + if name == "gmail": + profile = await source._get("https://gmail.googleapis.com/gmail/v1/users/me/profile") + email = profile.get("emailAddress", "") + elif name == "google_calendar": + profile = await source._get( + "https://www.googleapis.com/calendar/v3/users/me/calendarList/primary" + ) + email = profile.get("id", "") + if profile.get("primary") is not True: + raise ValueError("Calendar identity response did not identify the primary calendar") + elif name == "slack": + identity = await source._get("https://slack.com/api/auth.test") + if not identity.get("team_id") or not identity.get("user_id"): + raise ValueError("Slack did not return workspace and user identity") + profile = await source._get( + "https://slack.com/api/users.info", {"user": identity["user_id"]} + ) + if profile.get("user", {}).get("id") != identity["user_id"]: + raise ValueError("Slack returned a different user") + email = profile.get("user", {}).get("profile", {}).get("email", "") + else: + profile = await source._get( + "https://www.googleapis.com/drive/v3/about", params={"fields": "user(emailAddress)"} + ) + email = profile.get("user", {}).get("emailAddress", "") + if email.casefold() != expected_email.casefold(): + raise ValueError("Provider identity does not match the explicitly selected email") + + +@asynccontextmanager +async def rest_source( + name, + account, + expected_email, + key, + fence, + *, + gmail_query="newer_than:7d smaller:5M", + request_hook=None, + response_hook=None, + gmail_unfiltered=False, + calendar_config=None, + max_file_bytes=10 * 1024 * 1024, +): + host = {"gmail": "gmail.googleapis.com", "slack": "slack.com"}.get(name, "www.googleapis.com") + hosts = {host, "docs.googleapis.com"} if name == "google_drive" else {host} + auth = ManagedAuthProvider(api_key=key, connected_account_id=account, allowed_hosts=hosts) + transport = ComposioTransport(api_key=key, connected_account_id=account, allowed_hosts=hosts) + transport.MAX_BINARY_BYTES = max_file_bytes + hooks = {"request": [request_hook]} if request_hook is not None else {} + if response_hook is not None: + hooks["response"] = [response_hook] + async with httpx.AsyncClient(transport=transport, timeout=180, event_hooks=hooks) as client: + wrapped = AirweaveHttpClient( + client, fence.organization_id, name, feature_flag_enabled=False + ) + if name == "google_calendar": + calendar_config = calendar_config or GoogleCalendarConfig() + if calendar_config.expected_primary_calendar_id is None: + calendar_config = GoogleCalendarConfig.model_validate( + { + **calendar_config.model_dump(), + "expected_primary_calendar_id": os.environ["LIVE_CALENDAR_PRIMARY_ID"], + } + ) + source_type, config = { + "gmail": ( + GmailSource, + GmailConfig( + expected_mailbox=expected_email, + gmail_query=None, + included_labels=[], + excluded_labels=[], + excluded_categories=[], + after_date=None, + ) + if gmail_unfiltered + else GmailConfig(gmail_query=gmail_query, expected_mailbox=expected_email), + ), + "google_drive": ( + GoogleDriveSource, + GoogleDriveConfig(expected_permission_id=os.environ["LIVE_DRIVE_PERMISSION_ID"]) + if name == "google_drive" + else GoogleDriveConfig(), + ), + "google_calendar": ( + GoogleCalendarSource, + calendar_config or GoogleCalendarConfig(), + ), + "slack": ( + SlackSource, + SlackConfig( + expected_team_id=os.environ["LIVE_SLACK_TEAM_ID"], + expected_user_id=os.environ["LIVE_SLACK_USER_ID"], + ) + if name == "slack" + else SlackConfig(), + ), + }[name] + source = await source_type.create( + auth=auth, logger=logging.getLogger("probe"), http_client=wrapped, config=config + ) + # Sources attest their explicitly pinned native identity during create. + if name not in {"gmail", "google_calendar", "google_drive", "slack"}: + await verify_rest_identity(name, source, expected_email) + identity_kind = { + "slack": "provider_team_user_id", + "google_drive": "provider_permission_id", + "google_calendar": "provider_primary_calendar_id", + }.get(name, "provider_email") + yield source, identity_kind + + +@asynccontextmanager +async def wispr_source( + account, key, fence, *, request_hook=None, response_hook=None, envelope_hook=None +): + """Verify exact Composio account binding; Wispr exposes no identity profile here.""" + expected_user = os.environ["LIVE_WISPR_USER_ID"] + hooks = {"request": [request_hook]} if request_hook is not None else {} + if response_hook is not None: + hooks["response"] = [response_hook] + async with httpx.AsyncClient(timeout=180, event_hooks=hooks) as client: + metadata_response = await client.get( + "https://backend.composio.dev/api/v3/connected_accounts/" + quote(account, safe=""), + headers={"x-api-key": key}, + ) + metadata_response.raise_for_status() + metadata = metadata_response.json() + if ( + metadata.get("id") != account + or metadata.get("status") != "ACTIVE" + or metadata.get("toolkit", {}).get("slug") != "wispr_flow_mcp" + or metadata.get("user_id") != expected_user + ): + raise ValueError("Wispr connected-account identity/state mismatch") + source = await WisprSource.create( + auth=ManagedToolAuthProvider( + api_key=key, connected_account_id=account, user_id=expected_user + ), + logger=logging.getLogger("probe"), + http_client=AirweaveHttpClient( + client, fence.organization_id, "wispr", feature_flag_enabled=False + ), + ) + if envelope_hook is not None: + original_post = source._post + + async def observed_post(path, body): + result = await original_post(path, body) + envelope_hook(result) + return result + + source._post = observed_post + await source.validate() + yield source, "composio_account_principal_only" diff --git a/backend/tests/live/wispr_diagnostics.py b/backend/tests/live/wispr_diagnostics.py new file mode 100644 index 0000000000..5e62f36d80 --- /dev/null +++ b/backend/tests/live/wispr_diagnostics.py @@ -0,0 +1,69 @@ +"""Allowlisted diagnostic metadata; never retain provider error strings or arguments.""" + +import json +from typing import Literal + +from pydantic import BaseModel + + +class WisprDiagnostics(BaseModel): + operation: Literal["metadata", "session", "search", "get", "unknown"] = "unknown" + last_http_status: int | None = None + tool_error_present: bool | None = None + tool_error_kind: Literal["string", "object", "array", "boolean", "number", "none"] | None = None + rate_signal_detected: bool = False + + def request(self, request): + self.last_http_status = None + self.tool_error_present = self.tool_error_kind = None + self.rate_signal_detected = False + path = request.url.path + if "/connected_accounts/" in path: + self.operation = "metadata" + elif path.endswith("/tool_router/session"): + self.operation = "session" + elif path.endswith("/execute"): + try: + value = json.loads(request.content) + except (ValueError, UnicodeDecodeError): + value = None + slug = value.get("tool_slug") if isinstance(value, dict) else None + self.operation = { + "WISPR_FLOW_MCP_SEARCH_MEETINGS": "search", + "WISPR_FLOW_MCP_GET_MEETING": "get", + }.get(slug, "unknown") + else: + self.operation = "unknown" + + async def response(self, response): + self.last_http_status = response.status_code + self.rate_signal_detected = response.status_code == 429 + + def envelope(self, value): + if self.operation not in {"search", "get"}: + return + error = value.get("error") + self.tool_error_present = error is not None + if error is None: + self.tool_error_kind = "none" + elif isinstance(error, str): + self.tool_error_kind = "string" + lowered = error.lower() + self.rate_signal_detected |= "rate limit" in lowered or "too many requests" in lowered + elif isinstance(error, dict): + self.tool_error_kind = "object" + self.rate_signal_detected |= any( + type(error.get(key)) is int and error[key] == 429 + for key in ("code", "status", "status_code") + ) + elif isinstance(error, list): + self.tool_error_kind = "array" + elif isinstance(error, bool): + self.tool_error_kind = "boolean" + else: + self.tool_error_kind = "number" + + +async def observe_request(diagnostics, meter, request): + await meter(request) + diagnostics.request(request) diff --git a/backend/tests/live/wispr_resume.py b/backend/tests/live/wispr_resume.py new file mode 100644 index 0000000000..8a271cdb51 --- /dev/null +++ b/backend/tests/live/wispr_resume.py @@ -0,0 +1,151 @@ +"""Test-only committed-body interruption; no provider content in evidence or output.""" + +import hashlib +import json +import os +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from sqlalchemy import select + +from airweave.domains.entities.canonical.cycle_models import CaptureCycle +from airweave.models.capture_scan import CaptureScan +from airweave.models.entity import Entity +from airweave.models.sync import Sync +from airweave.models.sync_cursor import SyncCursor +from airweave.models.sync_job import SyncJob + + +class CompletedBody(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + scan_id: UUID + revision: int + record_type: Literal["meeting", "scratchpad_note"] + native_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class WisprTarget(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[3] + configuration_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + cycle_id: UUID + bodies: tuple[CompletedBody, ...] + + +class WisprResumeProbe: + """Stop before another call, only after SQL proves prior bodies committed.""" + + resumed_saved_page = False + saved_cursor_expired = False + + def __init__( + self, sessions, organization_id, sync_id, job_id, attempt_id, target_path, mode, counters + ): + self.sessions = sessions + self.organization_id, self.sync_id = organization_id, sync_id + self.job_id, self.attempt_id = job_id, attempt_id + self.target_path, self.mode, self.counters = target_path, mode, counters + try: + self.target = ( + WisprTarget.model_validate_json(target_path.read_text()) + if mode == "resume" + else None + ) + except ValidationError: + raise ValueError( + "Incompatible Wispr resume target; start a new private v3 trial" + ) from None + + async def before_page(self, scope, continuation): + if scope.record_type not in {"meeting", "scratchpad_note"}: + return + async with self.sessions() as db: + sync, job = await db.get(Sync, self.sync_id), await db.get(SyncJob, self.job_id) + assert sync.organization_id == job.organization_id == self.organization_id + assert sync.writer_job_id == job.id and sync.writer_attempt_id == self.attempt_id + assert job.status == "running" + cursor = await db.scalar( + select(SyncCursor).where( + SyncCursor.organization_id == self.organization_id, + SyncCursor.sync_id == self.sync_id, + ) + ) + if cursor is None: + raise ValueError("Wispr resume requires a persisted capture cycle") + cycle = CaptureCycle.model_validate(cursor.cursor_data.get("canonical_cycle")) + if cycle.configuration.parents != { + "meeting_listing": (None,), + "meeting": ("meeting_listing",), + "scratchpad_listing": (None,), + "scratchpad_note": ("scratchpad_listing",), + }: + raise ValueError("Wispr resume requires the v3 mixed-resource capture topology") + rows = ( + await db.execute( + select(CaptureScan, Entity.native_id) + .join(Entity, Entity.id == CaptureScan.parent_record_id) + .where( + CaptureScan.organization_id == self.organization_id, + CaptureScan.sync_id == self.sync_id, + CaptureScan.record_type.in_(("meeting", "scratchpad_note")), + CaptureScan.cycle_id == cycle.version.cycle_id, + CaptureScan.phase == "complete", + ) + ) + ).all() + bodies = tuple( + CompletedBody( + scan_id=row.id, + revision=row.revision, + record_type=row.record_type, + native_digest=hashlib.sha256(native.encode()).hexdigest(), + ) + for row, native in rows + ) + cycles = {row.cycle_id for row, _ in rows} + if self.target is not None: + assert cycles == {self.target.cycle_id} + assert cycle.configuration.digest() == self.target.configuration_digest, ( + "Capture configuration changed" + ) + assert all(body in bodies for body in self.target.bodies), "Completed sibling changed" + digest = hashlib.sha256(scope.parent.native_id.encode()).hexdigest() + assert (scope.record_type, digest) not in { + (body.record_type, body.native_digest) for body in self.target.bodies + }, "Completed body repeated" + if len(bodies) == len(self.target.bodies) + 1: + self._stop(True, bodies) + elif len(bodies) == 3: + assert len(cycles) == 1 + self.target_path.write_text( + WisprTarget( + schema_version=3, + configuration_digest=cycle.configuration.digest(), + cycle_id=next(iter(cycles)), + bodies=bodies, + ).model_dump_json() + ) + os.chmod(self.target_path, 0o600) + self._stop(False, bodies) + + def _stop(self, verified, bodies): + print( + json.dumps( + { + "failed": False, + "counters_complete": True, + "intentional_interruption": not verified, + "wispr_recovery_verified": verified, + "full_scope_completed": False, + "completed_bodies": len(bodies), + "completed_body_kinds": { + kind: sum(body.record_type == kind for body in bodies) + for kind in ("meeting", "scratchpad_note") + }, + **self.counters, + } + ), + flush=True, + ) + os._exit(76 if verified else 75) diff --git a/backend/tests/live/workspace_document.py b/backend/tests/live/workspace_document.py new file mode 100644 index 0000000000..2fe8b156d2 --- /dev/null +++ b/backend/tests/live/workspace_document.py @@ -0,0 +1,89 @@ +"""One explicit native Docs hydration sample; never a Drive enumeration or checkpoint.""" + +import asyncio +import hashlib +import os +import sys +from pathlib import Path + +from provider_sample import rest_source + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.storage.file_service import FileService +from airweave.platform.sources.records.google_drive import BASE, file_record +from airweave.platform.sources.records.workspace_manifest import canonical_json + + +async def fetch_workspace_document(account, email, key, fence, root): + storage = FilesystemBackend(root / "blobs") + files = FileService(fence.job_id, storage, sync_id=fence.sync_id) + files.MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 + requests = 0 + + async def count_request(request): + nonlocal requests + if request.method != "GET" or requests >= 12: + raise ValueError("Workspace read-only provider request budget exceeded") + requests += 1 + + async with ( + asyncio.timeout(150), + rest_source("google_drive", account, email, key, fence, request_hook=count_request) as ( + source, + identity_verification, + ), + ): + listing = await source._get( + BASE + "/files", + params={ + "q": "trashed=false and mimeType='application/vnd.google-apps.document'", + "pageSize": "1", + "fields": "files(*)", + }, + ) + items = listing.get("files", []) + if len(items) != 1: + raise ValueError("The selected account returned no document sample") + captured = await source._capture_body(file_record(items[0]), files) + if not any(blob.role == "representation_manifest" for blob in captured.blobs): + raise ValueError("The document sample has no structured representation") + # Fixed metadata only; never print native IDs, names or contents. + print('{"document_sample_provider_requests": ' + str(requests) + "}", flush=True) + return [captured], storage, identity_verification, 1 + + +async def verify_workspace_document_reader( + *, records, storage, query, sessions, organization_id, sync_id, root +): + from almanac_handoff import run_consumer + + if len(records) != 1: + raise ValueError("Workspace trial requires exactly one retained document") + record = records[0] + async with sessions() as db: + result = await query.document( + db, organization_id, sync_id, record.id, record.revision, storage + ) + expected = hashlib.sha256(canonical_json(result.document)).hexdigest() + backend = Path(__file__).resolve().parents[2] + consumer = Path(__file__).with_name("workspace_document_read.py") + repository = backend.parent + python = Path(sys.executable) + if os.environ.get("LIVE_ALMANAC_PYTHON"): + python = Path(os.environ["LIVE_ALMANAC_PYTHON"]).absolute() + repository = Path(os.environ["LIVE_ALMANAC_ROOT"]).resolve() + consumer = repository / "backend/tests/live/read_owned_document.py" + if not python.is_file() or not consumer.is_file(): + raise ValueError("Explicit Almanac Python and document consumer required") + return await run_consumer( + sessions=sessions, + organization_id=organization_id, + sync_id=sync_id, + root=root, + storage=storage, + query=query, + consumer=consumer, + repository=repository, + python=python, + extra={"record_id": str(record.id), "revision": record.revision, "expected": expected}, + ) diff --git a/backend/tests/live/workspace_document_read.py b/backend/tests/live/workspace_document_read.py new file mode 100644 index 0000000000..66922b1a86 --- /dev/null +++ b/backend/tests/live/workspace_document_read.py @@ -0,0 +1,71 @@ +"""Private subprocess read of the real stored-document HTTP route; no provider keys.""" + +import asyncio +import hashlib +import json +import logging +import sys +from pathlib import Path +from uuid import uuid4 + +import httpx + +logging.disable(logging.CRITICAL) + + +async def main(): + path = Path(sys.argv[1]).resolve() + if path.stat().st_mode & 0o077 or path.parent.stat().st_mode & 0o077: + raise ValueError("Private handoff permissions required") + config = json.loads(path.read_text()) + endpoint = f"/sync/{config['sync_id']}/records/{config['record_id']}/document" + async with httpx.AsyncClient( + base_url=config["origin"], + headers={"x-api-key": config["api_key"]}, + timeout=30, + trust_env=False, + follow_redirects=False, + ) as client: + response = await client.get(endpoint, params={"revision": config["revision"]}) + response.raise_for_status() + result = response.json() + assert result["id"] == config["record_id"] and result["revision"] == config["revision"] + encoded = json.dumps( + result["document"], + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode() + assert hashlib.sha256(encoded).hexdigest() == config["expected"] + assert result["manifest"]["file_id"] == result["document"]["documentId"] + assert result["manifest"]["native"]["status"] == "complete" + assert response.headers["cache-control"] == "private, no-store" + stale = await client.get(endpoint, params={"revision": config["revision"] + 1}) + assert stale.status_code == 409 + foreign = await client.get( + f"/sync/{uuid4()}/records/{config['record_id']}/document", + params={"revision": config["revision"]}, + ) + assert foreign.status_code == 404 + print( + json.dumps( + { + "verified": True, + "native_document_digest_verified": True, + "identity_verified": True, + "stale_revision_denied": True, + "wrong_source_denied": True, + "top_level_tabs": len(result["document"]["tabs"]), + "scope": "stored_document_http_route_with_fixture_auth", + } + ) + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception: + print(json.dumps({"verified": False, "stage": "exact_record_read"})) + raise SystemExit(1) from None diff --git a/backend/tests/unit/api/test_owned_search_cancellation.py b/backend/tests/unit/api/test_owned_search_cancellation.py new file mode 100644 index 0000000000..30bc2605a7 --- /dev/null +++ b/backend/tests/unit/api/test_owned_search_cancellation.py @@ -0,0 +1,178 @@ +"""Read-only search cancellation on real TCP, with explicit fixture dependencies.""" + +import asyncio +import socket +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import httpx +import pytest +import uvicorn +from fastapi import FastAPI, Header, HTTPException + +from airweave.api import deps +from airweave.api.v1.endpoints.records import router +from airweave.db.session import get_db +from airweave.domains.search.owned_models import OwnedSearchResponse + +BODY = {"query": "synthetic", "sync_ids": [str(uuid4())], "mode": "keyword"} +HEADERS = {"X-API-Key": "fixture-only"} + + +def response(): + return OwnedSearchResponse( + items=(), + sources=(), + candidate_window_full=False, + engine_partial=False, + excluded_candidates=0, + postfilter_excluded=0, + retrieval_incomplete=False, + ) + + +def app_for(search, released): + app = FastAPI() + app.include_router(router, prefix="/sync") + + async def database(): + try: + yield object() + finally: + released.set() + + async def context(x_api_key: str = Header()): + if x_api_key != "fixture-only": + raise HTTPException(401, "Invalid fixture key") + return SimpleNamespace(organization=SimpleNamespace(id=uuid4())) + + app.dependency_overrides[get_db] = database + app.dependency_overrides[deps.get_context] = context + app.dependency_overrides[deps.get_container] = lambda: SimpleNamespace( + owned_search=SimpleNamespace(search=search) + ) + return app + + +@pytest.mark.asyncio +async def test_search_post_preserves_response_errors_and_auth_without_disconnect_wait(): + released = asyncio.Event() + search = AsyncMock(return_value=response()) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app_for(search, released)), base_url="http://test" + ) as client: + async with asyncio.timeout(3): + good = await client.post("/sync/search", json=BODY, headers=HEADERS) + assert good.status_code == 200 and good.json()["items"] == [] + assert released.is_set() + search.side_effect = HTTPException(409, {"code": "reindex_required"}) + failed = await client.post("/sync/search", json=BODY, headers=HEADERS) + assert failed.status_code == 409 + assert failed.json()["detail"]["code"] == "reindex_required" + before = search.await_count + denied = await client.post("/sync/search", json=BODY, headers={"X-API-Key": "wrong"}) + assert denied.status_code == 401 and search.await_count == before + + +@pytest.mark.asyncio +async def test_tcp_search_disconnect_joins_work_before_database_dependency_closes(): + entered, cancelled, released, unblock = (asyncio.Event() for _ in range(4)) + + async def search(db, ctx, request): + assert request.query == BODY["query"] # Parsed POST body survives the listener. + entered.set() + try: + await unblock.wait() + return response() + except asyncio.CancelledError: + assert not released.is_set() + cancelled.set() + raise + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(16) + server = uvicorn.Server( + uvicorn.Config(app_for(search, released), lifespan="off", log_config=None, access_log=False) + ) + serving = asyncio.create_task(server.serve(sockets=[listener])) + try: + async with asyncio.timeout(5): + while not server.started: + if serving.done(): + await serving + await asyncio.sleep(0.001) + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + task = asyncio.create_task( + client.post( + f"http://127.0.0.1:{listener.getsockname()[1]}/sync/search", + json=BODY, + headers=HEADERS, + ) + ) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.wait_for(cancelled.wait(), timeout=2) + await asyncio.wait_for(released.wait(), timeout=2) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + finally: + unblock.set() + server.should_exit = True + try: + await asyncio.wait_for(serving, timeout=5) + finally: + listener.close() + + +@pytest.mark.asyncio +async def test_completed_search_error_wins_simultaneous_disconnect(): + from airweave.api.v1.endpoints.records import search_records + from airweave.domains.search.owned_models import OwnedSearchRequest + + error = HTTPException(409, {"code": "reindex_required"}) + container = SimpleNamespace(owned_search=SimpleNamespace(search=AsyncMock(side_effect=error))) + request = SimpleNamespace(receive=AsyncMock(return_value={"type": "http.disconnect"})) + with pytest.raises(HTTPException) as caught: + await search_records(OwnedSearchRequest(**BODY), request, object(), object(), container) + assert caught.value is error + + +@pytest.mark.asyncio +async def test_caller_cancellation_joins_search_and_disconnect_listener(): + from airweave.api.v1.endpoints.records import search_records + from airweave.domains.search.owned_models import OwnedSearchRequest + + entered = asyncio.Event() + listener_entered = asyncio.Event() + stopped = set() + + async def blocked(name, ready): + ready.set() + try: + await asyncio.Event().wait() + finally: + stopped.add(name) + + container = SimpleNamespace( + owned_search=SimpleNamespace(search=lambda *args: blocked("search", entered)) + ) + request = SimpleNamespace(receive=lambda: blocked("listener", listener_entered)) + task = asyncio.create_task( + search_records(OwnedSearchRequest(**BODY), request, object(), object(), container) + ) + try: + await asyncio.wait_for(entered.wait(), 2) + await asyncio.wait_for(listener_entered.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped == {"search", "listener"} + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) diff --git a/backend/tests/unit/domains/entities/test_attio_projection.py b/backend/tests/unit/domains/entities/test_attio_projection.py new file mode 100644 index 0000000000..e99ed8df2d --- /dev/null +++ b/backend/tests/unit/domains/entities/test_attio_projection.py @@ -0,0 +1,177 @@ +"""Attio search preserves original identity rather than joining CRM and membership.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import ( + ProjectionMappingError, + map_record, +) +from airweave.domains.entities.canonical.requests import RecordIdentity + +WORKSPACE, OBJECT, RECORD, LIST, ENTRY, NOTE = (str(uuid4()) for _ in range(6)) + + +def original(kind, native_id, payload, parent=None): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity( + record_type=kind, native_id=native_id, container_id=parent.native_id if parent else None + ), + parent=parent, + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="hash", + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +def entry(): + return original( + "entry", + ENTRY, + { + "id": {"workspace_id": WORKSPACE, "list_id": LIST, "entry_id": ENTRY}, + "parent_record_id": RECORD, + "parent_object": "people", + "entry_values": {"status": [{"status": {"title": "Interested"}}]}, + "unknown_native": "must survive", + }, + RecordIdentity(record_type="list", native_id=LIST), + ) + + +@pytest.mark.asyncio +async def test_membership_search_keeps_entry_identity_and_never_fetches_record(): + captured = entry() + before = captured.model_dump() + storage = AsyncMock() + async with map_record(captured, "attio", storage) as entities: + entities = entities.entities + assert len(entities) == 1 + result = entities[0] + assert result.native_id == ENTRY + assert result.original_kind == "entry" + assert RECORD in result.text and "Interested" in result.text + assert result.web_url == "" + assert "separate" in result.content_coverage + assert captured.model_dump() == before + assert storage.mock_calls == [] + + +@pytest.mark.asyncio +async def test_note_uses_current_plaintext_field_and_its_record_parent(): + captured = original( + "note", + NOTE, + { + "id": {"workspace_id": WORKSPACE, "note_id": NOTE}, + "title": "Call", + "parent_record_id": RECORD, + "parent_object": "people", + "content_plaintext": "Discussed fundraising", + "content_markdown": "**Discussed** fundraising", + }, + RecordIdentity(record_type="record", native_id=RECORD, container_id=OBJECT), + ) + async with map_record(captured, "attio", AsyncMock()) as entities: + entities = entities.entities + assert entities[0].text == "Discussed fundraising" + assert entities[0].title == "Call" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("corruption", ["entry_id", "list_id", "parent", "unavailable"]) +async def test_mismatched_or_unavailable_membership_never_projects(corruption): + captured = entry() + if corruption in {"entry_id", "list_id"}: + payload = captured.model_dump()["payload"] + payload["id"][corruption] = str(uuid4()) + captured = captured.model_copy(update={"payload": payload}) + elif corruption == "parent": + captured = captured.model_copy(update={"parent": None}) + else: + captured = captured.model_copy(update={"content_access": "unavailable"}) + with pytest.raises(ProjectionMappingError): + async with map_record(captured, "attio", AsyncMock()): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", ["https://app.attio.com/workspace/person/record", "https://evil.example/record"] +) +async def test_record_attributes_and_native_url(url): + captured = original( + "record", + RECORD, + { + "id": {"workspace_id": WORKSPACE, "object_id": OBJECT, "record_id": RECORD}, + "values": {"name": [{"full_name": "Sam Example"}], "custom_score": [{"value": 12}]}, + "web_url": url, + }, + RecordIdentity(record_type="object", native_id=OBJECT), + ) + if "evil" in url: + with pytest.raises(ProjectionMappingError): + async with map_record(captured, "attio", AsyncMock()): + pass + else: + async with map_record(captured, "attio", AsyncMock()) as entities: + entities = entities.entities + assert entities[0].title == "Sam Example" + assert "custom_score" in entities[0].text + assert entities[0].web_url == url + + +@pytest.mark.asyncio +async def test_two_memberships_for_one_crm_record_remain_distinct(): + first = entry() + second_entry, second_list = str(uuid4()), str(uuid4()) + payload = first.model_dump()["payload"] + payload["id"].update(entry_id=second_entry, list_id=second_list) + second = original( + "entry", + second_entry, + payload, + RecordIdentity(record_type="list", native_id=second_list), + ) + async with map_record(first, "attio", AsyncMock()) as one: + one = one.entities + async with map_record(second, "attio", AsyncMock()) as two: + two = two.entities + assert one[0].native_id != two[0].native_id + assert RECORD in one[0].text and RECORD in two[0].text + assert one[0].original_kind == two[0].original_kind == "entry" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["object", "list"]) +async def test_container_projection_does_not_invent_children(kind): + native_id = OBJECT if kind == "object" else LIST + payload = {"id": {"workspace_id": WORKSPACE, kind + "_id": native_id}} + if kind == "object": + payload.update(singular_noun="Person", plural_noun="People", api_slug="people") + else: + payload.update(name="Fundraising") + captured = original(kind, native_id, payload) + async with map_record(captured, "attio", AsyncMock()) as entities: + entities = entities.entities + assert len(entities) == 1 + assert "metadata only" in entities[0].content_coverage + assert entities[0].web_url == "" diff --git a/backend/tests/unit/domains/entities/test_github_projection.py b/backend/tests/unit/domains/entities/test_github_projection.py new file mode 100644 index 0000000000..cc3b446559 --- /dev/null +++ b/backend/tests/unit/domains/entities/test_github_projection.py @@ -0,0 +1,129 @@ +"""Offline search views must retain native text and use only owned blob storage.""" + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity + + +def record(kind, payload, *, native_id="id"): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type=kind, native_id=native_id), + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="a" * 64, + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +CONTEXT = {"repository_id": 1, "owner_id": 2, "full_name": "team/repo"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["comment", "review", "review_comment"]) +async def test_native_conversation_text_and_exact_kind(kind): + item = record( + kind, + { + "native": {"id": 3, "body": "Native **Markdown**", "state": "APPROVED"}, + "repository": CONTEXT, + "issue_number": 7, + }, + ) + storage = AsyncMock() + async with map_record(item, "github", storage) as views: + views = views.entities + assert views[0].text == "Native **Markdown**" + assert views[0].resource_kind == kind + assert views[0].state == "APPROVED" + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_code_uses_verified_owned_blob_and_temporary_file(): + content = b"print('original')\n" + item = record( + "file", + { + "entry": { + "path": "main.py", + "mode": "100644", + "type": "blob", + "sha": "a" * 40, + "size": len(content), + }, + "path": "src/main.py", + "ref": "main", + "commit_sha": "b" * 40, + "tree_sha": "c" * 40, + "repository": CONTEXT, + }, + native_id="src/main.py", + ) + digest = hashlib.sha256(content).hexdigest() + blob = BlobReference( + key=f"canonical/{item.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="text/x-python", + ) + item = item.model_copy(update={"blobs": (blob,)}) + storage = AsyncMock() + storage.read_file.return_value = content + async with map_record(item, "github", storage) as views: + views = views.entities + local = Path(views[0].local_path) + assert local.read_bytes() == content + assert views[0].commit_id == "b" * 40 + assert not local.exists() + storage.read_file.assert_awaited_once_with(blob.key, max_bytes=len(content)) + + +@pytest.mark.asyncio +async def test_missing_complete_bytes_keeps_projection_pending(): + item = record( + "file", + { + "entry": { + "path": "large", + "mode": "100644", + "type": "blob", + "sha": "a" * 40, + "size": 100, + }, + "path": "large", + "ref": "main", + "commit_sha": "b" * 40, + "tree_sha": "c" * 40, + "repository": CONTEXT, + }, + native_id="large", + ) + with pytest.raises(ValueError, match="retained blob"): + async with map_record(item, "github", AsyncMock()): + pass + partial = item.model_copy(update={"completeness": "partial"}) + async with map_record(partial, "github", AsyncMock()) as views: + assert views.parts[-1].entity is None and views.parts[-1].part.kind == "file" + assert views.parts[-1].part.key == "/file/content" + views = views.entities + assert views[0].content_coverage == "partial" and views[0].text == "" diff --git a/backend/tests/unit/domains/entities/test_projection_mappers.py b/backend/tests/unit/domains/entities/test_projection_mappers.py new file mode 100644 index 0000000000..0177ea4d77 --- /dev/null +++ b/backend/tests/unit/domains/entities/test_projection_mappers.py @@ -0,0 +1,419 @@ +"""Search projections preserve canonical data and cannot fetch missing source content.""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import ( + ProjectionMappingError, + map_record, +) +from airweave.domains.entities.canonical.requests import RecordIdentity + + +def record(record_type, payload, *, native_id="id", container_id=None): + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity( + record_type=record_type, native_id=native_id, container_id=container_id + ), + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="hash", + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +@pytest.mark.asyncio +async def test_calendar_keeps_master_recurrence_and_native_container(): + payload = { + "id": "event", + "summary": "Weekly", + "start": {"date": "2026-10-01"}, + "end": {"date": "2026-10-02"}, + "recurrence": ["RRULE:FREQ=WEEKLY"], + "unknown_native": "keep", + } + item = record("event", payload, native_id="event", container_id="calendar") + async with map_record(item, "google_calendar", AsyncMock()) as entities: + entities = entities.entities + assert entities[0].calendar_key == "calendar" + assert entities[0].recurrence == ["RRULE:FREQ=WEEKLY"] + assert entities[0].start_date == "2026-10-01" + assert item.payload == payload + + +@pytest.mark.asyncio +async def test_slack_history_shape_uses_container_without_provider_lookup(): + item = record( + "message", + {"ts": "123.456", "text": "hello", "edited": {"ts": "124.0"}}, + native_id="123.456", + container_id="C1", + ) + storage = AsyncMock() + async with map_record(item, "slack", storage) as entities: + entities = entities.entities + assert entities[0].channel_id == "C1" + assert entities[0].text == "hello" + assert entities[0].updated_at.timestamp() == 124 + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_drive_missing_blob_fails_without_remote_fallback(): + item = record("file", {"id": "file", "name": "file.pdf", "mimeType": "application/pdf"}) + with pytest.raises(ProjectionMappingError, match="blob"): + async with map_record(item, "google_drive", AsyncMock()): + pass + + +@pytest.mark.asyncio +async def test_wispr_ranges_do_not_duplicate_default_content(): + first = { + "id": "meeting", + "title": "Planning", + "summary": "summary", + "content": "notes", + "transcript": ( + "abc\n\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=3...)" + ), + } + second = {"id": "meeting", "content": "notes", "transcript": "def"} + payload = { + "responses": [ + { + "requested_ranges": { + "view_content": {"start_char": 0}, + "view_transcript": {"start_char": 0}, + }, + "response": first, + }, + {"requested_ranges": {"view_transcript": {"start_char": 3}}, "response": second}, + ] + } + async with map_record(record("meeting", payload), "wispr", AsyncMock()) as entities: + entities = entities.entities + assert entities[0].notes == "notes" + assert entities[0].transcript == "abcdef" + assert "guidance" not in entities[0].transcript + + +@pytest.mark.asyncio +async def test_wispr_unfinished_ranges_fail_instead_of_indexing_truncated_body(): + payload = { + "responses": [ + { + "requested_ranges": { + "view_content": {"start_char": 0}, + "view_transcript": {"start_char": 0}, + }, + "response": { + "content": "", + "transcript": ( + "abc\n\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=3...)" + ), + }, + } + ] + } + with pytest.raises(ProjectionMappingError, match="unfinished"): + async with map_record(record("meeting", payload), "wispr", AsyncMock()): + pass + + +@pytest.mark.asyncio +async def test_drive_uses_verified_owned_bytes_and_cleans_materialization(): + import hashlib + from pathlib import Path + + from airweave.domains.entities.canonical.requests import BlobReference + + content = b"owned document body" + item = record("file", {"id": "file", "name": "../../unsafe.txt", "mimeType": "text/plain"}) + digest = hashlib.sha256(content).hexdigest() + blob = BlobReference( + key=f"canonical/{item.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type="text/plain", + ) + item = item.model_copy(update={"blobs": (blob,)}) + storage = AsyncMock() + storage.read_file.return_value = content + async with map_record(item, "google_drive", storage) as entities: + entities = entities.entities + path = Path(entities[0].local_path) + assert path.read_bytes() == content + assert path.name == digest + ".txt" + assert entities[0].mime_type == "text/plain" + assert not path.exists() + storage.read_file.assert_awaited_once_with(blob.key, max_bytes=blob.size_bytes) + + +def test_added_typed_entities_are_registered(): + from airweave.domains.entities.registry import EntityDefinitionRegistry + from airweave.platform.entities.google_drive import GoogleDriveFolderEntity + from airweave.platform.entities.slack import SlackChannelEntity + from airweave.platform.entities.wispr import WisprMeetingEntity + + registry = EntityDefinitionRegistry() + registry.build() + for entity in (WisprMeetingEntity, GoogleDriveFolderEntity, SlackChannelEntity): + assert registry.get_short_name_by_class(entity) + + +async def test_owned_drive_spreadsheet_uses_real_converter(): + import hashlib + from io import BytesIO + from pathlib import Path + + import openpyxl + + from airweave.domains.converters.registry import ConverterRegistry + from airweave.domains.entities.canonical.requests import BlobReference + from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder + + workbook = openpyxl.Workbook() + workbook.active.append(["Project", "Status"]) + workbook.active.append(["Owned capture", "Verified"]) + buffer = BytesIO() + workbook.save(buffer) + content = buffer.getvalue() + digest = hashlib.sha256(content).hexdigest() + mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + item = record( + "file", + {"id": "sheet", "name": "Planning", "mimeType": "application/vnd.google-apps.spreadsheet"}, + ) + blob = BlobReference( + key=f"canonical/{item.sync_id}/blobs/sha256/{digest}", + sha256=digest, + size_bytes=len(content), + media_type=mime, + ) + item = item.model_copy(update={"blobs": (blob,)}) + storage = AsyncMock() + storage.read_file.return_value = content + async with map_record(item, "google_drive", storage) as entities: + entities = entities.entities + path = entities[0].local_path + assert Path(path).suffix == ".xlsx" + tracker = AsyncMock() + result = await TextualRepresentationBuilder(ConverterRegistry()).build_for_batch( + list(entities), + SimpleNamespace(source_short_name="google_drive", logger=MagicMock()), + SimpleNamespace(entity_tracker=tracker), + ) + assert len(result) == 1 + assert "Owned capture" in result[0].textual_representation + assert "Verified" in result[0].textual_representation + tracker.record_skipped.assert_not_awaited() + assert not Path(path).exists() + + +@pytest.mark.asyncio +async def test_wispr_scratchpad_retains_all_text_ranges_without_meeting_fields(): + payload = { + "responses": [ + { + "requested_ranges": {"view_content": {"start_char": 0}}, + "response": { + "id": "note", + "title": "Ideas", + "content": ( + "abc\n\n(...truncated, 3 chars remaining; " + "continue with view_content.start_char=3...)" + ), + "modified_at": "2026-09-30T00:00:00Z", + }, + }, + { + "requested_ranges": {"view_content": {"start_char": 3}}, + "response": { + "id": "note", + "title": "Ideas", + "content": "def", + "modified_at": "2026-09-30T00:00:00Z", + }, + }, + ] + } + original = record("scratchpad_note", payload, native_id="note") + before = original.model_dump() + storage = AsyncMock() + async with map_record(original, "wispr", storage) as entities: + entities = entities.entities + assert entities[0].note_id == "note" + assert entities[0].content == "abcdef" + assert entities[0].web_url == "" + assert original.model_dump() == before + assert storage.mock_calls == [] + + +@pytest.mark.asyncio +async def test_wispr_projection_rejects_ambiguous_stored_continuation(): + marker = "(...truncated, 3 chars remaining; continue with view_content.start_char=3...)" + payload = { + "responses": [ + { + "requested_ranges": {"view_content": {"start_char": 0}}, + "response": {"id": "note", "content": marker + "\n" + marker}, + } + ] + } + with pytest.raises(ProjectionMappingError, match="ambiguous"): + async with map_record( + record("scratchpad_note", payload, native_id="note"), "wispr", AsyncMock() + ): + pass + + +@pytest.mark.asyncio +async def test_wispr_explicitly_absent_transcript_keeps_available_notes(): + native = { + "id": "meeting", + "content": "available notes", + "has_transcript": False, + "transcript": None, + } + payload = { + "responses": [ + { + "requested_ranges": { + "view_content": {"start_char": 0}, + "view_transcript": {"start_char": 0}, + }, + "response": native, + } + ] + } + async with map_record(record("meeting", payload), "wispr", AsyncMock()) as result: + assert result.entities[0].notes == "available notes" + assert result.entities[0].transcript == "" + assert native["transcript"] is None + native["has_transcript"] = True + with pytest.raises(ProjectionMappingError, match="unavailable or malformed"): + async with map_record(record("meeting", payload), "wispr", AsyncMock()): + pass + + +@pytest.mark.parametrize("unit", ["codepoints", "utf16"]) +def test_wispr_ranges_preserve_unicode_and_source_whitespace(unit): + from airweave.domains.entities.canonical.projection_mappers import _wispr_text + + first = "स्वीकृत 😀 \n" + offset = len(first) if unit == "codepoints" else len(first.encode("utf-16-le")) // 2 + pages = [ + { + "requested_ranges": {"view_content": {"start_char": 0}}, + "response": { + "content": first + + "\n\n(...truncated, 3 chars remaining; " + + f"continue with view_content.start_char={offset}...)" + }, + }, + { + "requested_ranges": {"view_content": {"start_char": offset}}, + "response": {"content": "done \n\n"}, + }, + ] + assert _wispr_text(pages, "content") == first + "done \n\n" + + +def test_wispr_wrapped_transcript_preserves_midword_and_terminal_whitespace(): + import copy + + from airweave.domains.entities.canonical.projection_mappers import ( + _WISPR_TRANSCRIPT_FOOTER, + _WISPR_TRANSCRIPT_HEADER, + _wispr_text, + ) + + pages = [ + { + "requested_ranges": {"view_transcript": {"start_char": 0}}, + "response": { + "transcript": _WISPR_TRANSCRIPT_HEADER + + "appro\n\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=5...)" + _WISPR_TRANSCRIPT_FOOTER + }, + }, + { + "requested_ranges": {"view_transcript": {"start_char": 5}}, + "response": { + "transcript": _WISPR_TRANSCRIPT_HEADER + "ved \n" + _WISPR_TRANSCRIPT_FOOTER + }, + }, + ] + before = copy.deepcopy(pages) + assert _wispr_text(pages, "transcript") == "approved \n" + assert pages == before + + +@pytest.mark.parametrize("defect", ["separator", "footer", "length", "gap", "unit_change"]) +def test_wispr_inconsistent_range_framing_fails(defect): + from airweave.domains.entities.canonical.projection_mappers import _wispr_text + + pages = [ + { + "requested_ranges": {"view_content": {"start_char": 0}}, + "response": { + "content": "😀a\n\n(...truncated, 2 chars remaining; " + "continue with view_content.start_char=2...)" + }, + }, + {"requested_ranges": {"view_content": {"start_char": 2}}, "response": {"content": "bc"}}, + ] + if defect == "separator": + pages[0]["response"]["content"] = pages[0]["response"]["content"].replace("\n\n", "\n") + elif defect == "footer": + pages[0]["response"]["content"] += "\nUnknown guidance" + elif defect == "length": + pages[0]["response"]["content"] = pages[0]["response"]["content"].replace( + "start_char=2", "start_char=7" + ) + elif defect == "gap": + pages[1]["requested_ranges"]["view_content"]["start_char"] = 3 + else: + # First range requires codepoints, the next would require UTF16 units. + pages[1]["response"]["content"] = ( + "😀a\n\n(...truncated, 1 chars remaining; continue with view_content.start_char=5...)" + ) + with pytest.raises(ProjectionMappingError): + _wispr_text(pages, "content") + + +def test_wispr_terminal_transcript_rejects_incomplete_wrapper(): + from airweave.domains.entities.canonical.projection_mappers import ( + _WISPR_TRANSCRIPT_HEADER, + _wispr_text, + ) + + with pytest.raises(ProjectionMappingError, match="framing"): + _wispr_text( + [ + { + "requested_ranges": {"view_transcript": {"start_char": 0}}, + "response": {"transcript": _WISPR_TRANSCRIPT_HEADER + "intact text"}, + } + ], + "transcript", + ) diff --git a/backend/tests/unit/domains/entities/test_stripe_projection.py b/backend/tests/unit/domains/entities/test_stripe_projection.py new file mode 100644 index 0000000000..0bf2b848fd --- /dev/null +++ b/backend/tests/unit/domains/entities/test_stripe_projection.py @@ -0,0 +1,277 @@ +"""Offline synthetic Stripe projection; no provider or live-money claims.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.stripe_projection import map_stripe +from airweave.domains.sync_pipeline.pipeline.text_builder import TextualRepresentationBuilder +from airweave.platform.entities.stripe import ( + StripeEventEntity, + StripeInvoiceEntity, + StripePaymentIntentEntity, +) + + +def original(kind, **values): + native = "balance" if kind == "balance" else f"{kind}_123" + payload = {"object": kind, "livemode": False, **values} + if kind != "balance": + payload = {"id": native, "created": 1720000000, **payload} + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type=kind, native_id=native), + revision=1, + payload=payload, + payload_schema_version=1, + capture_hash="fixture", + content_hash=None, + completeness="partial", + observed_at=datetime(2026, 10, 1, tzinfo=timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +def test_invoice_payment_event_keep_separate_facts_and_ids(): + invoice = original( + "invoice", + number="INV-42", + amount_due=1200, + amount_paid=200, + amount_remaining=1000, + currency="jpy", + status="open", + customer={"id": "cus_1"}, + ) + payment = original( + "payment_intent", + amount=1200, + currency="jpy", + status="requires_action", + client_secret="pi_secret", + ) + event = original( + "event", + type="invoice.paid", + data={ + "object": { + "id": invoice.identity.native_id, + "object": "invoice", + "paid": True, + "amount_paid": 1200, + "metadata": {"secret": "nested_secret"}, + }, + "previous_attributes": {"client_secret": "previous_secret"}, + }, + ) + before = [item.model_dump() for item in (invoice, payment, event)] + inv, pi, evt = [map_stripe(item)[0] for item in (invoice, payment, event)] + assert isinstance(inv, StripeInvoiceEntity) and isinstance(pi, StripePaymentIntentEntity) + assert isinstance(evt, StripeEventEntity) + assert inv.entity_id == invoice.identity.native_id and inv.amount_remaining == 1000 + assert inv.amount_paid == 200 and inv.currency == "jpy" and inv.paid is None + assert pi.entity_id == payment.identity.native_id and pi.status == "requires_action" + assert evt.entity_id == event.identity.native_id and evt.event_type == "invoice.paid" + assert evt.data == {"object": {"id": invoice.identity.native_id, "object": "invoice"}} + assert all(entity.updated_at is None for entity in (inv, pi, evt)) + assert inv.updated_time is None and pi.updated_time is None + assert before == [item.model_dump() for item in (invoice, payment, event)] + + +def test_secrets_absent_from_both_full_entity_and_search_text(): + examples = [ + original( + "payment_intent", amount=500, client_secret="secret_a", metadata={"token": "secret_b"} + ), + original( + "payment_method", + type="card", + card={"number": "secret_c", "cvc": "secret_d", "fingerprint": "secret_e"}, + billing_details={"name": "Buyer", "email": "buyer@example.com", "token": "secret_f"}, + ), + original( + "balance", + available=[{"amount": 500, "currency": "usd", "token": "secret_g"}], + pending=[], + ), + original( + "event", + type="payment_intent.created", + data={ + "object": {"id": "pi_1", "object": "payment_intent", "client_secret": "secret_h"} + }, + ), + ] + builder = TextualRepresentationBuilder() + for record in examples: + entity = map_stripe(record)[0] + assert "secret_" not in entity.model_dump_json() + assert "secret_" not in builder.build_metadata_section(entity, "stripe") + assert "secret_" in str(record.payload) + + +@pytest.mark.parametrize( + "kind", + [ + "balance_transaction", + "charge", + "customer", + "invoice", + "payment_intent", + "payment_method", + "payout", + "refund", + "subscription", + ], +) +def test_resource_breadth_and_unknown_timestamps_flags(kind): + entity = map_stripe(original(kind))[0] + assert entity.entity_id == f"{kind}_123" + assert entity.created_at == datetime.fromtimestamp(1720000000, timezone.utc) + assert entity.updated_at is None + values = entity.model_dump() + for field in ( + "paid", + "captured", + "refunded", + "delinquent", + "cancel_at_period_end", + "updated_time", + ): + if field in values: + assert values[field] is None + + +@pytest.mark.parametrize( + "change", + [ + {"id": "other"}, + {"object": "charge"}, + {"created": "1720000000"}, + {"amount_paid": True}, + {"paid": "false"}, + ], +) +def test_identity_and_typed_field_mismatch_fail_without_payload_echo(change): + record = original("invoice", metadata={"token": "private_secret"}) + record = record.model_copy(update={"payload": {**record.payload, **change}}) + with pytest.raises(ValueError) as error: + map_stripe(record) + assert "private_secret" not in str(error.value) + + +def test_balance_uses_captured_snapshot_and_keeps_currency_buckets_separate(): + record = original( + "balance", + available=[{"amount": 500, "currency": "usd"}, {"amount": 1200, "currency": "jpy"}], + pending=[], + ) + entity = map_stripe(record)[0] + assert entity.snapshot_time == record.observed_at + assert entity.available == [ + {"amount": 500, "currency": "usd"}, + {"amount": 1200, "currency": "jpy"}, + ] + bad = record.model_copy( + update={"identity": RecordIdentity(record_type="balance", native_id="other")} + ) + with pytest.raises(ValueError, match="balance identity"): + map_stripe(bad) + + +def test_native_flags_references_and_financial_amounts_are_not_recomputed(): + charge = map_stripe( + original( + "charge", + amount=1000, + currency="usd", + paid=True, + captured=False, + refunded=False, + customer={"id": "cus_1", "metadata": {"token": "secret_expansion"}}, + ) + )[0] + assert charge.paid is True and charge.captured is False and charge.refunded is False + assert charge.customer_id == "cus_1" and "secret_expansion" not in charge.model_dump_json() + transaction = map_stripe( + original( + "balance_transaction", + amount=-1000, + fee=23, + net=-1023, + currency="usd", + source="ch_1", + ) + )[0] + assert (transaction.amount, transaction.fee, transaction.net) == (-1000, 23, -1023) + assert transaction.source == "ch_1" + refund = map_stripe( + original("refund", amount=200, charge="ch_1", payment_intent={"id": "pi_1"}) + )[0] + assert ( + refund.amount == 200 and refund.charge_id == "ch_1" and refund.payment_intent_id == "pi_1" + ) + + +def test_missing_creation_time_and_wrong_capture_scope_fail(): + record = original("invoice") + for bad in ( + record.model_copy(update={"payload": {**record.payload, "created": None}}), + record.model_copy(update={"payload_schema_version": 2}), + record.model_copy( + update={ + "identity": RecordIdentity( + record_type="invoice", + native_id=record.identity.native_id, + container_id="cus_1", + ) + } + ), + ): + with pytest.raises(ValueError): + map_stripe(bad) + + +def test_legacy_constructors_keep_explicit_flags_and_timestamps(): + from airweave.platform.entities.stripe import StripeChargeEntity, StripeSubscriptionEntity + + charge = StripeChargeEntity.from_api( + {"id": "ch_1", "created": 1720000000, "paid": True, "captured": False, "refunded": False}, + web_url=None, + ) + invoice = StripeInvoiceEntity.from_api( + {"id": "in_1", "created": 1720000000, "paid": True}, + web_url=None, + ) + subscription = StripeSubscriptionEntity.from_api( + {"id": "sub_1", "created": 1720000000, "cancel_at_period_end": False}, + web_url=None, + ) + assert charge.paid is True and charge.captured is False and charge.refunded is False + assert invoice.paid is True and subscription.cancel_at_period_end is False + assert charge.created_time == datetime(2024, 7, 3, 9, 46, 40) + + +@pytest.mark.asyncio +async def test_shared_projection_dispatch_uses_retained_original_without_storage_reads(): + from unittest.mock import AsyncMock + + from airweave.domains.entities.canonical.projection_mappers import map_record + + storage = AsyncMock() + record = original("invoice", number="INV-42", currency="usd", amount_due=1200) + async with map_record(record, "stripe", storage) as projection: + assert len(projection.parts) == 1 + assert isinstance(projection.parts[0].entity, StripeInvoiceEntity) + assert projection.parts[0].entity.invoice_id == record.identity.native_id + assert not storage.mock_calls diff --git a/backend/tests/unit/domains/entities/test_workspace_docs.py b/backend/tests/unit/domains/entities/test_workspace_docs.py new file mode 100644 index 0000000000..bc323a2a93 --- /dev/null +++ b/backend/tests/unit/domains/entities/test_workspace_docs.py @@ -0,0 +1,294 @@ +"""Native Docs representation preserves structure and yields one deliberate search view.""" + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.projection_mappers import map_record +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity +from airweave.domains.entities.canonical.workspace_docs import DocumentReadError, read_document + + +def paragraph(text): + return {"paragraph": {"elements": [{"textRun": {"content": text}}]}} + + +def document(): + return { + "documentId": "doc", + "title": "Native title", + "futureDocumentField": {"keep": True}, + "tabs": [ + { + "tabProperties": {"tabId": "first", "title": "First"}, + "documentTab": { + "body": { + "content": [ + paragraph("Body"), + { + "table": { + "tableRows": [ + { + "tableCells": [{"content": [paragraph("Cell")]}], + } + ] + } + }, + ] + }, + "headers": {"h": {"content": [paragraph("Header")]}}, + "footers": {"f": {"content": [paragraph("Footer")]}}, + "footnotes": {"n": {"content": [paragraph("Footnote")]}}, + }, + "childTabs": [ + { + "tabProperties": {"tabId": "child", "title": "Nested"}, + "documentTab": {"body": {"content": [paragraph("Child content")]}}, + "unknownTabProperty": 12, + } + ], + } + ], + } + + +def capture(native=None, *, schema_version=1): + native = document() if native is None else native + sync = uuid4() + contents = {} + + def blob(value, media_type="application/json", role=None): + data = value if isinstance(value, bytes) else json.dumps(value).encode() + digest = hashlib.sha256(data).hexdigest() + key = f"canonical/{sync}/blobs/sha256/{digest}" + contents[key] = data + return BlobReference( + key=key, sha256=digest, size_bytes=len(data), media_type=media_type, role=role + ) + + native_blob = blob(native) + export = blob(b"NOT THE SEARCH BODY", "application/octet-stream") + manifest = blob( + { + "schema_version": schema_version, + "file_id": "doc", + "drive_version": "42", + "export": {"status": "retained", "blob": export.sha256, "reason": None}, + "native": { + "kind": "docs", + "status": "complete", + "document_blob": native_blob.sha256, + "include_tabs_content": True, + "suggestions_view": "DEFAULT_FOR_CURRENT_ACCESS", + "comments_view": "COMMENTS_VIEW_MODE_OMITTED", + "embedded_media": "not_retained", + "media": [], + "missing": [], + }, + }, + role="representation_manifest", + ) + record = SourceRecord( + id=uuid4(), + sync_id=sync, + identity=RecordIdentity(record_type="file", native_id="doc"), + revision=1, + payload={ + "id": "doc", + "name": "Document", + "version": "42", + "mimeType": "application/vnd.google-apps.document", + }, + payload_schema_version=1, + capture_hash="hash", + content_hash=manifest.sha256, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(export, native_blob, manifest), + indexed_revision=None, + indexed_pipeline_version=None, + ) + storage = AsyncMock() + + async def read(key, *, max_bytes): + assert len(contents[key]) <= max_bytes + return contents[key] + + storage.read_file.side_effect = read + return record, storage, contents + + +@pytest.mark.asyncio +async def test_recursive_tabs_preserve_original_structure_without_edit_revision(): + record, storage, _ = capture() + result = await read_document(record, storage) + assert result.document == document() + assert "revisionId" not in result.document + assert result.tab("child") == document()["tabs"][0]["childTabs"][0] + with pytest.raises(DocumentReadError, match="not present"): + result.tab("missing") + text = result.text() + for expected in ("Body", "Cell", "Header", "Footer", "Footnote", "Child content"): + assert text.count(expected) == 1 + + +@pytest.mark.asyncio +async def test_projection_reads_native_once_and_never_indexes_export(): + record, storage, _ = capture() + async with map_record(record, "google_drive", storage) as entities: + entities = entities.entities + assert len(entities) == 1 + entity = entities[0] + assert entity.file_id == "doc" + assert entity.mime_type == "text/plain" + text = Path(entity.local_path).read_text() + assert "Child content" in text and "NOT THE SEARCH BODY" not in text + assert all(call.args[0] != record.blobs[0].key for call in storage.read_file.call_args_list) + + +@pytest.mark.asyncio +async def test_unknown_text_shape_keeps_original_read_but_fails_projection(): + native = document() + native["tabs"][0]["documentTab"]["body"]["content"].append( + { + "paragraph": {"elements": [{"futureText": {"content": "Do not silently lose"}}]}, + } + ) + record, storage, _ = capture(native) + assert (await read_document(record, storage)).document == native + with pytest.raises(DocumentReadError, match="Unsupported"): + async with map_record(record, "google_drive", storage): + pass + + +@pytest.mark.asyncio +async def test_unavailable_record_does_not_read_storage(): + record, storage, _ = capture() + with pytest.raises(DocumentReadError, match="unavailable"): + await read_document(record.model_copy(update={"content_access": "unavailable"}), storage) + storage.read_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_native_bytes_are_verified_not_trusted_from_manifest(): + record, storage, contents = capture() + contents[record.blobs[1].key] = b"corrupt" + with pytest.raises(ValueError, match="size and digest"): + await read_document(record, storage) + + +@pytest.mark.asyncio +async def test_manifest_cannot_resolve_a_blob_outside_its_record(): + record, storage, _ = capture() + record = record.model_copy(update={"blobs": (record.blobs[0], record.blobs[2])}) + with pytest.raises(ValueError, match="account"): + await read_document(record, storage) + assert storage.read_file.await_count == 1 + + +@pytest.mark.asyncio +async def test_native_document_identity_must_match_drive_original(): + native = document() + native["documentId"] = "other-file" + record, storage, _ = capture(native) + with pytest.raises(ValueError, match="another file"): + await read_document(record, storage) + + +@pytest.mark.asyncio +async def test_duplicate_tab_ids_are_not_resolved_by_first_match(): + native = document() + native["tabs"][0]["childTabs"][0]["tabProperties"]["tabId"] = "first" + record, storage, _ = capture(native) + with pytest.raises(ValueError, match="duplicated"): + await read_document(record, storage) + + +@pytest.mark.asyncio +async def test_drive_version_mismatch_does_not_read_native_response(): + record, storage, _ = capture() + record = record.model_copy(update={"payload": {**record.payload, "version": "43"}}) + with pytest.raises(ValueError, match="version"): + await read_document(record, storage) + assert storage.read_file.await_count == 1 + + +@pytest.mark.asyncio +async def test_smart_chip_display_text_is_used_without_following_link(): + native = document() + native["tabs"][0]["documentTab"]["body"]["content"] = [ + { + "paragraph": { + "elements": [ + { + "person": { + "personProperties": {"name": "Alice", "email": "private@example.com"} + } + }, + {"textRun": {"content": " and "}}, + {"person": {"personProperties": {"email": "bob@example.com"}}}, + {"textRun": {"content": " read "}}, + { + "richLink": { + "richLinkProperties": { + "title": "Roadmap", + "uri": "https://untrusted.example/never-fetch", + } + } + }, + ] + } + } + ] + record, storage, _ = capture(native) + result = await read_document(record, storage) + assert "Alice and bob@example.com read Roadmap" in result.text() + assert "untrusted.example" not in result.text() + assert "private@example.com" not in result.text() + assert storage.read_file.await_count == 2 + + +@pytest.mark.asyncio +async def test_equation_is_retained_but_not_invented_as_search_text(): + native = document() + native["tabs"][0]["documentTab"]["body"]["content"] = [ + {"paragraph": {"elements": [{"equation": {}}]}}, + ] + record, storage, _ = capture(native) + result = await read_document(record, storage) + assert result.document == native + with pytest.raises(DocumentReadError, match="equation text"): + result.text() + + +@pytest.mark.asyncio +async def test_unsupported_manifest_never_falls_back_to_export(): + record, storage, _ = capture(schema_version=2) + with pytest.raises(ValueError): + async with map_record(record, "google_drive", storage): + pass + assert storage.read_file.await_count == 1 + assert storage.read_file.call_args.args[0] == record.blobs[2].key + + +@pytest.mark.asyncio +async def test_incomplete_native_fields_are_not_published_as_complete_text(): + record, storage, _ = capture() + result = await read_document(record, storage) + native = result.manifest.native.model_copy(update={"status": "partial"}) + partial = result.model_copy( + update={"manifest": result.manifest.model_copy(update={"native": native})} + ) + assert partial.tab("child")["tabProperties"]["tabId"] == "child" + with pytest.raises(DocumentReadError, match="incomplete"): + partial.text() diff --git a/backend/tests/unit/domains/storage/test_bounded_read.py b/backend/tests/unit/domains/storage/test_bounded_read.py new file mode 100644 index 0000000000..a9ccf78b63 --- /dev/null +++ b/backend/tests/unit/domains/storage/test_bounded_read.py @@ -0,0 +1,73 @@ +"""Actual oversized storage objects cannot bypass a canonical reference size bound.""" + +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest + +from airweave.adapters.storage.aws_s3 import S3Backend +from airweave.adapters.storage.azure_blob import AzureBlobBackend +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.adapters.storage.gcp_gcs import GCSBackend +from airweave.domains.storage.exceptions import StorageReadLimitExceeded + + +async def test_filesystem_reads_actual_bound_and_preserves_unbounded_callers(tmp_path): + storage = FilesystemBackend(tmp_path) + await storage.write_file("blob", b"12345") + with pytest.raises(StorageReadLimitExceeded): + await storage.read_file("blob", max_bytes=3) + assert await storage.read_file("blob", max_bytes=5) == b"12345" + assert await storage.read_file("blob") == b"12345" + await storage.write_file("empty", b"") + assert await storage.read_file("empty", max_bytes=0) == b"" + + +@pytest.mark.parametrize("cancel", [False, True]) +async def test_s3_stream_closes_on_oversize_and_cancellation(cancel): + storage = S3Backend(bucket="synthetic", region="us-east-1") + stream = AsyncMock() + stream.__aenter__.return_value = stream + stream.read.side_effect = asyncio.CancelledError() if cancel else None + stream.read.return_value = b"1234" + client = AsyncMock() + client.get_object.return_value = {"Body": stream} + storage._get_client = AsyncMock(return_value=client) + with pytest.raises(asyncio.CancelledError if cancel else StorageReadLimitExceeded): + await storage.read_file("blob", max_bytes=3) + stream.read.assert_awaited_once_with(4) + stream.__aexit__.assert_awaited_once() + + +async def test_azure_range_is_bounded_before_sdk_buffers(): + storage = AzureBlobBackend.__new__(AzureBlobBackend) + storage._resolve = lambda path: path + downloader = AsyncMock() + downloader.readall.return_value = b"1234" + blob = AsyncMock() + blob.download_blob.return_value = downloader + container = Mock() + container.get_blob_client.return_value = blob + storage._get_container_client = AsyncMock(return_value=container) + with pytest.raises(StorageReadLimitExceeded): + await storage.read_file("blob", max_bytes=3) + blob.download_blob.assert_awaited_once_with(offset=0, length=4) + + +async def test_gcs_range_is_bounded_before_sdk_buffers(): + storage = GCSBackend(bucket="synthetic") + blob = Mock() + blob.download_as_bytes.return_value = b"1234" + bucket = Mock() + bucket.blob.return_value = blob + storage._get_bucket = Mock(return_value=bucket) + with pytest.raises(StorageReadLimitExceeded): + await storage.read_file("blob", max_bytes=3) + blob.download_as_bytes.assert_called_once_with(start=0, end=3, raw_download=True) + + +async def test_storage_does_not_disguise_programming_errors(): + storage = S3Backend(bucket="synthetic", region="us-east-1") + storage._get_client = AsyncMock(side_effect=TypeError("programming error")) + with pytest.raises(TypeError): + await storage.read_file("blob", max_bytes=3) diff --git a/backend/tests/unit/domains/storage/test_import_order.py b/backend/tests/unit/domains/storage/test_import_order.py new file mode 100644 index 0000000000..86e1e80caf --- /dev/null +++ b/backend/tests/unit/domains/storage/test_import_order.py @@ -0,0 +1,27 @@ +"""Fresh-interpreter storage imports must not depend on eager source catalogue order.""" + +import subprocess +import sys +from pathlib import Path + + +def test_file_service_imports_before_source_catalogue(): + root = Path(__file__).resolve().parents[4] + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import conftest; " + "from airweave.domains.storage.file_service import FileService; " + "from airweave.platform.sources import GmailSource, GoogleDriveSource; " + "assert FileService and GmailSource and GoogleDriveSource" + ), + ], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/backend/tests/unit/platform/destinations/vespa/test_filter_translator.py b/backend/tests/unit/platform/destinations/vespa/test_filter_translator.py index 1084e4a19b..2f20132331 100644 --- a/backend/tests/unit/platform/destinations/vespa/test_filter_translator.py +++ b/backend/tests/unit/platform/destinations/vespa/test_filter_translator.py @@ -165,16 +165,17 @@ def test_range_multiple_operators(self, filter_translator): assert " AND " in result def test_range_datetime_conversion(self, filter_translator): - """Test range condition with datetime string converts to epoch ms.""" + """Test range condition with datetime string converts to epoch seconds.""" filter_dict = { "must": [ {"key": "created_at", "range": {"gte": "2024-01-01T00:00:00Z"}} ] } result = filter_translator.translate(filter_dict) - # Should convert to epoch milliseconds + # Match the transformer and schema: stored timestamps are epoch seconds. assert "created_at >=" in result - assert "1704067200000" in result # 2024-01-01T00:00:00Z in epoch ms + assert "1704067200" in result + assert "1704067200000" not in result # ========================================================================= # HasId Conditions @@ -565,3 +566,32 @@ def test_field_name_map_values_correct(self): assert FIELD_NAME_MAP["source_name"] == "airweave_system_metadata_source_name" assert FIELD_NAME_MAP["access.is_public"] == "access_is_public" + + +@pytest.mark.parametrize("field", ["created_at", "updated_at"]) +@pytest.mark.parametrize("boundary", ["2024-01-01T00:00:00Z", "2023-12-31T16:00:00-08:00"]) +def test_date_filter_matches_actual_feed_timestamp(filter_translator, field, boundary): + """A document on the inclusive boundary must not disappear due to unit mismatch.""" + from airweave.platform.entities.slack import SlackChannelEntity + from airweave.platform.destinations.vespa.transformer import EntityTransformer + + instant = datetime.fromisoformat("2024-01-01T00:00:00+00:00") + entity = SlackChannelEntity(channel_id="boundary", title="Boundary", purpose="", topic="", breadcrumbs=[]) + entity.created_at = entity.updated_at = instant + stored = EntityTransformer()._build_base_fields(entity)[field] + assert stored == 1704067200 + clause = filter_translator._translate_range_condition( + {"key": field, "range": {"gte": boundary, "lt": "2024-01-01T00:00:01Z"}} + ) + assert clause == f"{field} >= {stored} AND {field} < {stored + 1}" + + +def test_subsecond_boundary_is_not_rounded_into_another_second(filter_translator): + clause = filter_translator._translate_range_condition( + {"key": "created_at", "range": {"gte": "2024-01-01T00:00:00.5Z"}} + ) + assert clause == "created_at >= 1704067200.5" + + +def test_naive_legacy_timestamp_is_explicitly_utc(filter_translator): + assert filter_translator._parse_datetime_to_epoch_seconds("2024-01-01T00:00:00") == 1704067200 diff --git a/backend/tests/unit/platform/http_client/test_composio_transport.py b/backend/tests/unit/platform/http_client/test_composio_transport.py new file mode 100644 index 0000000000..cb3bd11830 --- /dev/null +++ b/backend/tests/unit/platform/http_client/test_composio_transport.py @@ -0,0 +1,400 @@ +"""Exercise credential custody, provider errors, and actual proxy binary shapes.""" + +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from airweave.core.shared_models import SourceConnectionErrorCategory +from airweave.domains.auth_provider.exceptions import ( + AuthProviderAuthError, + AuthProviderRateLimitError, + AuthProviderServerError, +) +from airweave.domains.sources.exceptions.classifier import classify_error +from airweave.domains.sources.token_providers.protocol import ( + ManagedAuthProvider, + authorization_headers, +) +from airweave.platform.http_client.composio_transport import ComposioProxyError, ComposioTransport + + +@pytest.mark.asyncio +async def test_account_binding_headers_and_repeated_query(): + async def proxy(request): + data = json.loads(request.content) + assert data["connected_account_id"] == "ca_bound" + assert data["endpoint"].endswith("?label=A&label=B") + assert all( + x["name"].lower() not in {"authorization", "cookie", "x-api-key"} + for x in data["parameters"] + ) + assert all(x["type"] == "header" and "in" not in x for x in data["parameters"]) + assert request.headers["x-api-key"] == "private-key" + return httpx.Response( + 200, + json={"status": 429, "data": {"error": "limited"}, "headers": {"retry-after": "17"}}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="private-key", + connected_account_id="ca_bound", + allowed_hosts={"gmail.googleapis.com"}, + ) + ) as client: + result = await client.get( + "https://gmail.googleapis.com/gmail/v1/users/me?label=A&label=B", + headers={"authorization": "must-not-forward", "cookie": "secret"}, + ) + assert result.status_code == 429 + assert result.headers["retry-after"] == "17" + assert not upstream.is_closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,exception,category", + [ + ( + 401, + AuthProviderAuthError, + SourceConnectionErrorCategory.AUTH_PROVIDER_CREDENTIALS_INVALID, + ), + (429, AuthProviderRateLimitError, SourceConnectionErrorCategory.RATE_LIMITED), + (503, AuthProviderServerError, None), + ], +) +async def test_proxy_error_classification(status, exception, category): + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(status)) + ) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"slack.com"}, + ) + ) as client: + with pytest.raises(exception) as caught: + await client.get("https://slack.com/api/auth.test") + assert classify_error(caught.value).category == category + + +@pytest.mark.asyncio +async def test_binary_envelope_download_does_not_leak_credentials(monkeypatch, caplog): + checked = [] + monkeypatch.setattr( + "airweave.platform.http_client.composio_transport.validate_url", checked.append + ) + + async def proxy(_): + return httpx.Response( + 200, + json={ + "status": 200, + "data": {}, + "binary_data": { + "url": "https://files.example/file?signature=secret", + "size": 7, + "content_type": "application/pdf", + "expires_at": "2026-10-01", + }, + "headers": {"content-encoding": "gzip"}, + }, + ) + + async def download(request): + assert "authorization" not in request.headers + assert "x-api-key" not in request.headers + assert "cookie" not in request.headers + return httpx.Response(200, content=b"%PDF-ok") + + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as upstream, + ): + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + download_transport=httpx.MockTransport(download), + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"www.googleapis.com"}, + ) + ) as client: + result = await client.get("https://www.googleapis.com/drive/v3/files/id/export") + assert result.content == b"%PDF-ok" + assert result.headers["content-type"] == "application/pdf" + assert "content-encoding" not in result.headers + assert checked == ["https://files.example/file?signature=secret"] + assert "signature=secret" not in caplog.text + + +@pytest.mark.asyncio +async def test_reject_cross_provider_before_proxy(): + proxy = AsyncMock() + async with httpx.AsyncClient( + transport=ComposioTransport( + client=proxy, + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"slack.com"}, + ) + ) as client: + with pytest.raises(ComposioProxyError): + await client.get("https://evil.example/api") + proxy.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_explicit_managed_auth_has_no_token(): + auth = ManagedAuthProvider( + api_key="secret", connected_account_id="ca_bound", allowed_hosts=frozenset({"slack.com"}) + ) + assert await authorization_headers(auth) == {} + assert not hasattr(auth, "get_token") + assert "secret" not in repr(auth) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("data", ["plain text", {"items": [1]}]) +async def test_plaintext_and_json_envelopes(data): + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response(200, json={"status": 200, "data": data}) + ) + ) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"www.googleapis.com"}, + ) + ) as client: + result = await client.get("https://www.googleapis.com/drive/v3/files/id/export") + assert (result.text if isinstance(data, str) else result.json()) == data + + +@pytest.mark.asyncio +async def test_download_rejects_private_redirect(): + from airweave.platform.http_client.composio_transport import BinaryData + from airweave.platform.utils.ssrf import SSRFViolation + + calls = [] + + async def download(request): + calls.append(str(request.url)) + return httpx.Response(302, headers={"location": "https://169.254.169.254/latest"}) + + async with ComposioTransport( + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"www.googleapis.com"}, + download_transport=httpx.MockTransport(download), + ) as transport: + with pytest.raises(SSRFViolation): + await transport._download( + BinaryData( + url="https://files.example/file", + size=1, + content_type="application/pdf", + expires_at="2026-10-01", + ) + ) + assert calls == ["https://files.example/file"] + + +@pytest.mark.asyncio +async def test_download_stream_enforces_bytes_even_when_size_is_wrong(): + from airweave.platform.http_client.composio_transport import DownloadStream + + response = httpx.Response(200, content=b"12345") + stream = DownloadStream(response, max_bytes=3) + try: + with pytest.raises(ComposioProxyError, match="size limit"): + async for _ in stream: + pass + finally: + await stream.aclose() + assert response.is_closed + + +@pytest.mark.asyncio +async def test_direct_oauth_authentication_still_works(): + from airweave.domains.sources.token_providers.static import StaticTokenProvider + + assert await authorization_headers(StaticTokenProvider("direct")) == { + "Authorization": "Bearer direct" + } + + +@pytest.mark.asyncio +async def test_managed_provider_account_binding(monkeypatch): + from airweave.domains.auth_provider.exceptions import AuthProviderConfigError + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "key"}, config={"account_id": "ca_bound"} + ) + fetch = AsyncMock( + return_value={"id": "ca_bound", "toolkit": {"slug": "gmail"}, "status": "ACTIVE"} + ) + monkeypatch.setattr(provider, "_get_with_auth", fetch) + result = await provider.get_auth_result("gmail", ["access_token"]) + assert result.credentials is None + assert result.managed_auth.connected_account_id == "ca_bound" + assert result.managed_auth.allowed_hosts == {"gmail.googleapis.com"} + with pytest.raises(AuthProviderConfigError, match="toolkit"): + await provider.get_auth_result("google_drive", ["access_token"]) + + +@pytest.mark.asyncio +async def test_expired_account_is_auth_failure_not_generic_422(): + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 422, json={"error": {"code": 1706, "slug": "TOOL_AUTH_BadConnectedAccountState"}} + ) + ) + ) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"slack.com"}, + ) + ) as client: + with pytest.raises(AuthProviderAuthError, match="reauthorization"): + await client.get("https://slack.com/api/auth.test") + + +@pytest.mark.asyncio +async def test_proxy_envelope_is_bounded_before_json_decode(): + closed = [] + + class TooLarge(httpx.AsyncByteStream): + def __aiter__(self): + return self + + async def __anext__(self): + if closed: + raise StopAsyncIteration + return b"x" * (1024 * 1024 + 5) + + async def aclose(self): + closed.append(True) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=TooLarge())) + ) as upstream: + transport = ComposioTransport( + client=upstream, + api_key="key", + connected_account_id="ca_bound", + allowed_hosts={"gmail.googleapis.com"}, + ) + transport.MAX_BINARY_BYTES = 1 + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(ComposioProxyError, match="size limit"): + await client.get("https://gmail.googleapis.com/gmail/v1/users/me/messages/id") + assert closed + # Flush HTTPX's nested async iterator finalizers before this test loop closes. + import asyncio + + await asyncio.get_running_loop().shutdown_asyncgens() + + +@pytest.mark.asyncio +async def test_drive_binding_allows_native_workspace_only_with_same_verified_toolkit(monkeypatch): + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "key"}, config={"account_id": "ca_drive"} + ) + monkeypatch.setattr( + provider, + "_get_with_auth", + AsyncMock( + return_value={ + "id": "ca_drive", + "toolkit": {"slug": "googledrive"}, + "status": "ACTIVE", + } + ), + ) + result = await provider.get_auth_result("google_drive", ["access_token"]) + assert result.managed_auth.connected_account_id == "ca_drive" + assert result.managed_auth.allowed_hosts == { + "www.googleapis.com", + "docs.googleapis.com", + "sheets.googleapis.com", + } + assert "untrusted.example" not in result.managed_auth.allowed_hosts + + +@pytest.mark.asyncio +async def test_attio_binding_uses_only_native_api_host_and_verified_account(monkeypatch): + from airweave.domains.auth_provider.exceptions import AuthProviderConfigError + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "key"}, config={"account_id": "ca_attio"} + ) + fetch = AsyncMock( + return_value={ + "id": "ca_attio", + "toolkit": {"slug": "attio"}, + "status": "ACTIVE", + } + ) + monkeypatch.setattr(provider, "_get_with_auth", fetch) + result = await provider.get_auth_result("attio", ["access_token"]) + assert result.credentials is None + assert result.managed_auth.connected_account_id == "ca_attio" + assert result.managed_auth.allowed_hosts == {"api.attio.com"} + fetch.return_value = {"id": "ca_other", "toolkit": {"slug": "attio"}, "status": "ACTIVE"} + with pytest.raises(AuthProviderConfigError, match="identity"): + await provider.get_auth_result("attio", ["access_token"]) + + +@pytest.mark.asyncio +async def test_slack_binding_permits_exact_file_host_not_lookalike(monkeypatch): + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "key"}, config={"account_id": "ca_slack"} + ) + monkeypatch.setattr( + provider, + "_get_with_auth", + AsyncMock( + return_value={"id": "ca_slack", "toolkit": {"slug": "slack"}, "status": "ACTIVE"} + ), + ) + result = await provider.get_auth_result("slack", ["access_token"]) + assert result.managed_auth.allowed_hosts == {"slack.com", "files.slack.com"} + calls = [] + + async def proxy(request): + calls.append(json.loads(request.content)["endpoint"]) + return httpx.Response(200, json={"status": 200, "data": "file", "headers": {}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key="key", + connected_account_id="ca_slack", + allowed_hosts=result.managed_auth.allowed_hosts, + ) + ) as client: + await client.get("https://files.slack.com/files-pri/original.pdf") + with pytest.raises(ComposioProxyError): + await client.get("https://files.slack.com.evil.example/original.pdf") + assert calls == ["https://files.slack.com/files-pri/original.pdf"] diff --git a/backend/tests/unit/platform/http_client/test_logging_privacy.py b/backend/tests/unit/platform/http_client/test_logging_privacy.py new file mode 100644 index 0000000000..7d0a6867b5 --- /dev/null +++ b/backend/tests/unit/platform/http_client/test_logging_privacy.py @@ -0,0 +1,42 @@ +"""Verbose application logging must not enable raw HTTP transport traces.""" + +import asyncio +import logging + +import httpx +import pytest + +from airweave.core.logging import logger # noqa: F401 - service logging initialization + + +@pytest.mark.asyncio +async def test_verbose_application_logging_excludes_url_and_response_cookie(caplog): + """Exercise actual HTTPX/HTTPCore logging over a disposable loopback connection.""" + caplog.set_level(logging.DEBUG) + + async def respond(reader, writer): + try: + await reader.readuntil(b"\r\n\r\n") + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n" + b"Set-Cookie: session=private-cookie-sentinel\r\n" + b"Connection: close\r\n\r\nOK" + ) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + async with await asyncio.start_server(respond, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + async with httpx.AsyncClient(trust_env=False) as client: + response = await client.get( + f"http://127.0.0.1:{port}/private-path-sentinel?token=private-query-sentinel" + ) + assert response.text == "OK" + + logging.getLogger("httpx").warning("Transport warning remains observable") + assert "Transport warning remains observable" in caplog.text + assert "private-path-sentinel" not in caplog.text + assert "private-query-sentinel" not in caplog.text + assert "private-cookie-sentinel" not in caplog.text diff --git a/backend/tests/unit/platform/http_client/test_notion_proxy.py b/backend/tests/unit/platform/http_client/test_notion_proxy.py new file mode 100644 index 0000000000..38fecb8405 --- /dev/null +++ b/backend/tests/unit/platform/http_client/test_notion_proxy.py @@ -0,0 +1,138 @@ +"""Notion uses the existing account-bound proxy without exposing provider tokens.""" + +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from airweave.domains.auth_provider.exceptions import AuthProviderConfigError +from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider +from airweave.platform.http_client.composio_transport import ComposioProxyError, ComposioTransport + + +@pytest.mark.asyncio +async def test_notion_binding_preserves_version_body_and_native_error(monkeypatch): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "project-secret"}, + config={"account_id": "ca_notion", "auth_config_id": "ac_notion"}, + ) + fetch = AsyncMock( + return_value={ + "id": "ca_notion", + "toolkit": {"slug": "notion"}, + "status": "ACTIVE", + "auth_config": {"id": "ac_notion"}, + } + ) + monkeypatch.setattr(provider, "_get_with_auth", fetch) + result = await provider.get_auth_result("notion", ["access_token"]) + assert result.credentials is None + auth = result.managed_auth + assert auth.allowed_hosts == frozenset({"api.notion.com"}) + assert fetch.call_args.args[1].endswith("/connected_accounts/ca_notion") + body = {"filter": {"value": "page", "property": "object"}, "start_cursor": "opaque"} + error = { + "object": "error", + "status": 400, + "code": "validation_error", + "message": "Invalid cursor", + } + + async def proxy(request): + payload = json.loads(request.content) + assert payload["connected_account_id"] == "ca_notion" + assert payload["endpoint"] == "https://api.notion.com/v1/search" + assert payload["method"] == "POST" + assert payload["body"] == body + headers = {item["name"]: item["value"] for item in payload["parameters"]} + assert headers["notion-version"] == "2026-03-11" + assert headers["content-type"] == "application/json" + assert not {"authorization", "cookie", "x-api-key"}.intersection(headers) + assert "provider-secret" not in request.content.decode() + assert request.headers["x-api-key"] == "project-secret" + return httpx.Response(200, json={"status": 400, "data": error}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(proxy)) as upstream: + async with httpx.AsyncClient( + transport=ComposioTransport( + client=upstream, + api_key=auth.api_key.get_secret_value(), + connected_account_id=auth.connected_account_id, + allowed_hosts=auth.allowed_hosts, + ) + ) as client: + response = await client.post( + "https://api.notion.com/v1/search", + json=body, + headers={ + "Notion-Version": "2026-03-11", + "Authorization": "Bearer provider-secret", + "Cookie": "provider-secret", + "x-api-key": "provider-secret", + }, + ) + assert response.status_code == 400 + assert response.json() == error + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://prod-files-secure.s3.us-west-2.amazonaws.com/file", + "https://api.notion.com.evil.example/v1/search", + "https://www.notion.so/page", + "http://api.notion.com/v1/search", + "https://api.notion.com:444/v1/search", + "https://user@api.notion.com/v1/search", + ], +) +async def test_notion_rejects_unapproved_destinations_before_proxy(url): + proxy = AsyncMock() + async with httpx.AsyncClient( + transport=ComposioTransport( + client=proxy, + api_key="secret", + connected_account_id="ca_notion", + allowed_hosts={"api.notion.com"}, + ) + ) as client: + with pytest.raises(ComposioProxyError): + await client.get(url) + proxy.stream.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "account,toolkit,config_id,reason", + [ + (None, "notion", "ac_notion", "explicit Composio account"), + ("ca_notion", "github", "ac_notion", "toolkit"), + ("ca_notion", "notion", "other", "auth config"), + ], +) +async def test_notion_rejects_missing_account_or_mismatched_binding( + monkeypatch, + account, + toolkit, + config_id, + reason, +): + provider = await ComposioAuthProvider.create( + credentials={"api_key": "secret"}, + config={"account_id": account, "auth_config_id": "ac_notion"}, + ) + fetch = AsyncMock( + return_value={ + "id": account, + "toolkit": {"slug": toolkit}, + "status": "ACTIVE", + "auth_config": {"id": config_id}, + } + ) + monkeypatch.setattr(provider, "_get_with_auth", fetch) + with pytest.raises(AuthProviderConfigError, match=reason): + await provider.get_auth_result("notion", ["access_token"]) + if account is None: + fetch.assert_not_called() diff --git a/backend/tests/unit/platform/sources/records/test_calendar_transport.py b/backend/tests/unit/platform/sources/records/test_calendar_transport.py new file mode 100644 index 0000000000..e60d7ab91e --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_calendar_transport.py @@ -0,0 +1,137 @@ +"""Actual decorated native boundary with synthetic HTTP and managed-provider failures.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from airweave.domains.auth_provider.exceptions import ( + AuthProviderRateLimitError, + AuthProviderServerError, +) +from airweave.domains.entities.canonical.page_source import InvalidScanContinuation +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.sources.google_calendar import GoogleCalendarSource + + +async def source(client, auth=None): + return await GoogleCalendarSource.create( + auth=auth or StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=client, + config=GoogleCalendarConfig(), + ) + + +@pytest.mark.parametrize( + "error", [AuthProviderServerError(status_code=503), AuthProviderRateLimitError(retry_after=60)] +) +async def test_managed_transient_retries_and_honors_delay(error): + replies = [error, httpx.Response(200, json={"items": []})] + calls = [] + + async def handler(request): + calls.append(request) + value = replies.pop(0) + if isinstance(value, Exception): + raise value + return value + + sleep = AsyncMock() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + instance = await source(client) + call = GoogleCalendarSource._get_capture_json.retry_with(sleep=sleep) + assert await call(instance, "https://example.test") == {"items": []} + assert len(calls) == 2 + if isinstance(error, AuthProviderRateLimitError): + sleep.assert_awaited_once_with(60) + + +async def test_long_managed_retry_after_is_deferred_without_early_request(): + calls = [] + + async def handler(request): + calls.append(request) + raise AuthProviderRateLimitError(retry_after=180) + + sleep = AsyncMock() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + instance = await source(client) + with pytest.raises(AuthProviderRateLimitError): + await GoogleCalendarSource._get_capture_json.retry_with(sleep=sleep)( + instance, "https://example.test" + ) + assert len(calls) == 1 + sleep.assert_not_awaited() + + +async def test_one_auth_refresh_and_only_explicit_invalid_token_resets(): + class RefreshingToken(StaticTokenProvider): + refreshes = 0 + + @property + def supports_refresh(self): + return True + + async def force_refresh(self): + self.refreshes += 1 + self._token = "refreshed" + return self._token + + auth = RefreshingToken("initial") + replies = [ + httpx.Response(401, json={}), + httpx.Response(200, json={"items": []}), + httpx.Response( + 400, + json={ + "error": { + "errors": [ + {"reason": "invalid", "location": "pageToken", "locationType": "parameter"} + ] + } + }, + ), + httpx.Response(400, json={"error": {"message": "invalid unrelated parameter"}}), + ] + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: replies.pop(0)) + ) as client: + instance = await source(client, auth) + assert await instance._get_capture_json("https://example.test") == {"items": []} + with pytest.raises(InvalidScanContinuation): + await instance._get_capture_json("https://example.test", params={"pageToken": "old"}) + with pytest.raises(SourceError): + await instance._get_capture_json("https://example.test", params={"pageToken": "old"}) + assert auth.refreshes == 1 and not replies + + +async def test_oversized_native_body_closes_before_full_download(): + class Chunks(httpx.AsyncByteStream): + observed = 0 + closed = False + + def __aiter__(self): + self.iterator = self.chunks() + return self.iterator + + async def chunks(self): + for _ in range(40): + self.observed += 1 + yield b"x" * 1024 * 1024 + + async def aclose(self): + self.closed = True + await self.iterator.aclose() + + chunks = Chunks() + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, stream=chunks)) + ) as client: + instance = await source(client) + with pytest.raises(FileSkippedException): + await instance._get_capture_json("https://example.test") + assert chunks.observed == 33 and chunks.closed diff --git a/backend/tests/unit/platform/sources/records/test_google_calendar.py b/backend/tests/unit/platform/sources/records/test_google_calendar.py new file mode 100644 index 0000000000..32cd363bc8 --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_google_calendar.py @@ -0,0 +1,193 @@ +"""Calendar native pagination and resource rules; recovery is verified with real SQL separately.""" + +from copy import deepcopy + +import pytest + +from airweave.domains.entities.canonical.calendar import CalendarScopeContext +from airweave.domains.entities.canonical.page_source import InvalidScopeCheckpoint, ScopeAccessLost +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.sources.exceptions import ( + SourceEntityNotFoundError, + SourceGoneError, + SourceServerError, +) +from airweave.platform.configs.config import GoogleCalendarConfig +from airweave.platform.sources.records.calendar_pages import CalendarPages, CalendarProgress +from airweave.platform.sources.records.google_calendar import record + + +class ScriptedGet: + def __init__(self, replies): + self.replies = iter(replies) + self.calls = [] + + async def __call__(self, url, params=None): + self.calls.append((url, deepcopy(params))) + value = next(self.replies) + if isinstance(value, Exception): + raise value + return value + + +def progress(kind="event", **updates): + params = {"maxResults": 250, "showDeleted": "true", "singleEvents": "false"} + if kind == "event_occurrence": + params.update( + singleEvents="true", timeMin="2026-03-01T00:00:00Z", timeMax="2026-04-01T00:00:00Z" + ) + return CalendarProgress( + mode="full", context=CalendarScopeContext(parameters=params), **updates + ).continuation() + + +async def test_full_pagination_retains_sparse_cancellation_and_container(): + get = ScriptedGet( + [ + { + "items": [{"id": "series", "recurrence": ["RRULE:FREQ=WEEKLY"]}], + "nextPageToken": "page2", + }, + { + "items": [ + { + "id": "exception", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"date": "2026-10-01"}, + } + ], + "nextSyncToken": "boundary", + }, + ] + ) + pages = CalendarPages(get, GoogleCalendarConfig()) + scope = CompletedScope(record_type="event", container_id="team/a@example.com") + first = await pages.page(scope, progress()) + assert not first.final and first.provider_checkpoint is None + second = await pages.page(scope, first.continuation) + assert second.final and second.provider_checkpoint.value == {"sync_token": "boundary"} + assert ( + second.records[0].kind == "upsert" + and second.records[0].identity.container_id == scope.container_id + ) + assert "%2F" in get.calls[0][0] and get.calls[1][1]["pageToken"] == "page2" + + +async def test_410_only_delta_is_scope_reset_other_errors_fail(): + scope = CompletedScope(record_type="event", container_id="cal") + pages = CalendarPages(ScriptedGet([SourceGoneError("expired")]), GoogleCalendarConfig()) + delta = CalendarProgress.model_validate(progress().value).model_copy( + update={"mode": "changes", "sync_token": "old"} + ) + with pytest.raises(InvalidScopeCheckpoint): + await pages.page(scope, delta.continuation()) + for error in (SourceGoneError("full gone"), SourceServerError("temporary", status_code=503)): + with pytest.raises(type(error)): + await CalendarPages(ScriptedGet([error]), GoogleCalendarConfig()).page( + scope, progress() + ) + + +def test_only_recurring_cancellations_are_retained_and_reinstatement_keeps_identity(): + cancelled = { + "id": "exception", + "status": "cancelled", + "recurringEventId": "series", + "originalStartTime": {"dateTime": "2026-10-01T10:00:00Z"}, + } + exclusion = record("event", cancelled, "calendar") + active = record("event", {**cancelled, "status": "confirmed"}, "calendar") + assert exclusion.kind == active.kind == "upsert" and exclusion.identity == active.identity + assert exclusion.payload == cancelled + assert record("event", {"id": "single", "status": "cancelled"}, "calendar").kind == "delete" + with pytest.raises(ValueError, match="original occurrence"): + record( + "event", {"id": "bad", "status": "cancelled", "recurringEventId": "series"}, "calendar" + ) + + +async def test_occurrence_window_and_budgets_survive_continuation(): + scope = CompletedScope(record_type="event_occurrence", container_id="cal") + get = ScriptedGet( + [ + {"items": [{"id": "a"}], "nextPageToken": "p2"}, + {"items": [{"id": "b", "status": "cancelled"}]}, + ] + ) + pages = CalendarPages(get, GoogleCalendarConfig()) + first = await pages.page(scope, progress("event_occurrence")) + second = await pages.page(scope, first.continuation) + assert ( + second.final and second.provider_checkpoint is None and second.records[0].kind == "delete" + ) + assert get.calls[0][1]["timeMin"] == get.calls[1][1]["timeMin"] + assert second.continuation.value["records"] == 2 + with pytest.raises(ValueError, match="budget"): + await CalendarPages( + ScriptedGet([{"items": [{"id": "excess"}]}]), GoogleCalendarConfig() + ).page(scope, progress("event_occurrence", records=10000)) + + +async def test_repeated_pages_and_oversized_native_pages_fail(): + scope = CompletedScope(record_type="event_occurrence", container_id="cal") + pages = CalendarPages( + ScriptedGet([{"items": [], "nextPageToken": "same"}] * 2), GoogleCalendarConfig() + ) + first = await pages.page(scope, progress("event_occurrence")) + with pytest.raises(ValueError, match="repeated"): + await pages.page(scope, first.continuation) + with pytest.raises(ValueError, match="bounded page"): + await CalendarPages( + ScriptedGet([{"items": [{"id": str(i)} for i in range(251)]}]), GoogleCalendarConfig() + ).page(scope, progress("event_occurrence")) + + +async def test_exact_missing_membership_and_accessible_omission_are_different(): + pages = CalendarPages(ScriptedGet([SourceEntityNotFoundError("gone")]), GoogleCalendarConfig()) + await pages.confirm_member_absent("missing") + with pytest.raises(ValueError, match="omitted accessible"): + await CalendarPages( + ScriptedGet([{"id": "visible"}]), GoogleCalendarConfig() + ).confirm_member_absent("visible") + with pytest.raises(ScopeAccessLost): + await CalendarPages( + ScriptedGet([SourceEntityNotFoundError("gone")]), GoogleCalendarConfig() + ).page(CompletedScope(record_type="event", container_id="cal"), progress()) + + +def test_explicit_historical_window_rolling_defaults_and_selection_are_bounded(): + from datetime import timedelta + + assert ( + GoogleCalendarConfig().resolved_window().end + - GoogleCalendarConfig().resolved_window().start + == timedelta(days=120) + ) + assert ( + GoogleCalendarConfig( + occurrence_window={"start": "2001-01-01T00:00:00Z", "end": "2001-02-01T00:00:00Z"} + ) + .resolved_window() + .start.year + == 2001 + ) + with pytest.raises(ValueError): + GoogleCalendarConfig( + occurrence_window={"start": "2001-01-01T00:00:00Z", "end": "2003-02-01T00:00:00Z"} + ) + assert ( + GoogleCalendarConfig().calendar_ids is None + and GoogleCalendarConfig(calendar_ids=[]).calendar_ids == () + ) + for value in (["primary"], ["a", "a"], [""], [" a"]): + with pytest.raises(ValueError): + GoogleCalendarConfig(calendar_ids=value) + + +def test_record_stamps_native_dates_without_inventing_capture_dates(): + native = {"id": "event", "created": "2026-03-01T00:00:00Z", "updated": "2026-03-02T00:00:00Z"} + captured = record("event", native, "cal") + assert captured.source_created_at.day == 1 and captured.source_updated_at.day == 2 + assert captured.payload == native + assert record("calendar", {"id": "cal"}).source_created_at is None diff --git a/backend/tests/unit/platform/sources/records/test_google_docs_content.py b/backend/tests/unit/platform/sources/records/test_google_docs_content.py new file mode 100644 index 0000000000..ffb8653a77 --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_google_docs_content.py @@ -0,0 +1,190 @@ +"""Real bounded HTTP acquisition and immutable filesystem storage; no provider calls.""" + +import json +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import httpx +import pytest + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.sources.exceptions import ( + SourceAuthError, + SourceRateLimitError, + SourceServerError, +) +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.file_service import FileService +from airweave.platform.sources.records import google_docs_content +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_content import capture_file_content +from airweave.platform.sources.records.workspace_manifest import parse_manifest + +DOCUMENT = { + "documentId": "doc", + "title": "All tabs", + "tabs": [ + { + "tabProperties": {"tabId": "one"}, + "documentTab": {"body": {"content": []}}, + "childTabs": [{"tabProperties": {"tabId": "two"}, "documentTab": {"unknown": "kept"}}], + } + ], +} + + +@pytest.fixture +def files(tmp_path, monkeypatch): + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + return FileService(uuid4(), FilesystemBackend(tmp_path / "storage"), sync_id=uuid4()) + + +async def acquire(files, handler, *, version="7"): + record = file_record( + {"id": "doc", "mimeType": "application/vnd.google-apps.document", "version": "7"} + ) + get = AsyncMock(return_value={"version": version}) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await capture_file_content( + record, + files=files, + get=get, + client=client, + auth=StaticTokenProvider("test"), + logger=Mock(), + ) + return result, get + + +def response(request): + if request.url.host == "docs.googleapis.com": + assert request.url.params["includeTabsContent"] == "true" + assert request.url.params["commentsViewMode"] == "COMMENTS_VIEW_MODE_OMITTED" + assert request.url.params["suggestionsViewMode"] == "DEFAULT_FOR_CURRENT_ACCESS" + assert "fields" not in request.url.params + return httpx.Response(200, json=DOCUMENT) + return httpx.Response(200, content=b"synthetic-docx") + + +async def manifest_for(record, files): + marked = next(blob for blob in record.blobs if blob.role) + content = await files.storage.read_file(marked.key) + return parse_manifest(content, file_id="doc", drive_version="7", blobs=record.blobs) + + +async def test_all_tabs_native_and_export_are_published_together_and_deterministic(files): + first, get = await acquire(files, response) + second, _ = await acquire(files, response) + assert first.blobs == second.blobs and first.content_hash == second.content_hash + assert len(first.blobs) == 3 and first.completeness == "complete" + assert first.payload == { + "id": "doc", + "mimeType": "application/vnd.google-apps.document", + "version": "7", + } + manifest = await manifest_for(first, files) + native = next(blob for blob in first.blobs if blob.sha256 == manifest.native.document_blob) + assert json.loads(await files.storage.read_file(native.key)) == DOCUMENT + assert get.await_count == 1 + + +async def test_changed_drive_version_rejects_staged_representations(files): + with pytest.raises(ValueError, match="changed during"): + await acquire(files, response, version="8") + # Uncommitted immutable bytes can remain; no source blob GC is claimed. + assert len(await files.storage.list_files()) == 3 + + +async def test_native_success_survives_explicit_export_size_limit(files): + def limited(request): + if request.url.host == "docs.googleapis.com": + return response(request) + return httpx.Response( + 403, json={"error": {"errors": [{"reason": "exportSizeLimitExceeded"}]}} + ) + + record, _ = await acquire(files, limited) + manifest = await manifest_for(record, files) + assert record.completeness == "complete" + assert manifest.export.reason == "export_size_limit" and manifest.native.status == "complete" + + +async def test_embedded_objects_are_explicitly_partial_not_downloaded(files): + def media(request): + if request.url.host == "docs.googleapis.com": + return httpx.Response( + 200, + json={ + **DOCUMENT, + "inlineObjects": { + "image": { + "embeddedObject": { + "imageProperties": {"contentUri": "https://foreign.invalid/image"} + } + } + }, + }, + ) + return response(request) + + record, _ = await acquire(files, media) + manifest = await manifest_for(record, files) + assert record.completeness == "partial" + assert manifest.native.status == "complete" and manifest.native.embedded_media == "not_retained" + assert manifest.native.missing[0].source_path == "/inlineObjects/image/embeddedObject" + + +@pytest.mark.parametrize( + "status,error", [(401, SourceAuthError), (429, SourceRateLimitError), (500, SourceServerError)] +) +async def test_oversized_error_is_never_successful_native_gap(files, monkeypatch, status, error): + monkeypatch.setattr(google_docs_content, "MAX_DOCS_BYTES", 20) + + def failure(request): + if request.url.host == "docs.googleapis.com": + return httpx.Response(status, content=b"x" * 100, headers={"Retry-After": "300"}) + return response(request) + + with pytest.raises(error): + await acquire(files, failure) + + +async def test_successful_oversize_records_explicit_native_gap(files, monkeypatch): + monkeypatch.setattr(google_docs_content, "MAX_DOCS_BYTES", 20) + record, _ = await acquire(files, response) + manifest = await manifest_for(record, files) + assert record.completeness == "partial" + assert ( + manifest.native.document_blob is None + and manifest.native.missing[0].reason == "read_size_limit" + ) + + +async def test_foreign_document_and_storage_failure_do_not_publish(files): + def foreign(request): + if request.url.host == "docs.googleapis.com": + return httpx.Response(200, json={**DOCUMENT, "documentId": "foreign"}) + return response(request) + + with pytest.raises(ValueError, match="another file"): + await acquire(files, foreign) + files.store_canonical_blob = AsyncMock(side_effect=ConnectionError("storage unavailable")) + with pytest.raises(ConnectionError): + await acquire(files, response) + + +async def test_identical_part_bytes_share_one_workspace_descriptor_without_losing_uses(files): + from airweave.platform.sources.records.workspace_manifest import canonical_json + + def identical(request): + return httpx.Response(200, content=canonical_json(DOCUMENT)) + + record, _ = await acquire(files, identical) + manifest = await manifest_for(record, files) + assert len(record.blobs) == 2 + assert manifest.export.blob == manifest.native.document_blob + part = next(blob for blob in record.blobs if blob.role is None) + assert part.media_type == "application/octet-stream" diff --git a/backend/tests/unit/platform/sources/records/test_google_drive.py b/backend/tests/unit/platform/sources/records/test_google_drive.py new file mode 100644 index 0000000000..8e905dc13f --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_google_drive.py @@ -0,0 +1,129 @@ +"""Drive bounded acquisition; lifecycle checkpoint proof lives in canonical SQL tests.""" + +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.page_source import InvalidCaptureCheckpoint +from airweave.domains.sources.exceptions import ( + SourceEntityForbiddenError, + SourceEntityNotFoundError, +) +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_pages import DrivePages, _Progress + + +class ScriptedGet: + def __init__(self, replies): + self.replies = iter(replies) + self.calls = [] + + async def __call__(self, url, params): + self.calls.append((url, params.copy())) + value = next(self.replies) + if isinstance(value, Exception): + raise value + return value + + +async def identity(record): + return record + + +async def test_pending_native_ids_resume_without_relisting_and_terminal_only_checkpoint(): + get = ScriptedGet( + [ + {"files": [{"id": "one"}, {"id": "two"}]}, + {"id": "one"}, + {"id": "two"}, + { + "changes": [{"changeType": "file", "fileId": "one", "removed": True}], + "newStartPageToken": "after", + }, + {"id": "one", "trashed": True}, + ] + ) + first = await DrivePages(get).page( + _Progress(phase="list", boundary="before").continuation(), identity + ) + assert first.provider_checkpoint is None and first.continuation.value["pending"] == ["two"] + second = await DrivePages(get).page(first.continuation, identity) + assert second.provider_checkpoint is None + final = await DrivePages(get).page(second.continuation, identity) + assert final.final and final.provider_checkpoint.value == {"page_token": "after"} + assert final.records[0].kind == "upsert" and final.records[0].payload["trashed"] + assert get.calls[3][1]["pageToken"] == "before" + assert len([url for url, _ in get.calls if url.endswith("/files")]) == 1 + + +@pytest.mark.parametrize( + "raw,error", + [ + ({"incompleteSearch": True}, ValueError), + ({"files": [{"id": str(n)} for n in range(101)]}, ValidationError), + ], +) +async def test_incomplete_or_oversized_listing_cannot_return_progress(raw, error): + with pytest.raises(error): + await DrivePages(ScriptedGet([raw])).page( + _Progress(phase="list", boundary="before").continuation(), identity + ) + + +async def test_shared_drive_event_requires_whole_cycle_restart(): + with pytest.raises(InvalidCaptureCheckpoint): + await DrivePages(ScriptedGet([{"changes": [{"changeType": "drive"}]}])).page( + _Progress(phase="changes", boundary="before", token="before").continuation(), identity + ) + + +async def test_exact_unavailability_is_not_a_general_provider_error(): + record = await DrivePages(ScriptedGet([SourceEntityNotFoundError("gone")])).current("one") + assert record.kind == "delete" and record.removal_reason == "scope_removed" + with pytest.raises(SourceEntityForbiddenError): + await DrivePages(ScriptedGet([SourceEntityForbiddenError("forbidden")])).current("one") + with pytest.raises(ValueError, match="different file"): + await DrivePages(ScriptedGet([{"id": "other"}])).current("one") + + +def test_continuation_bound_and_native_dates(): + state = _Progress( + phase="changes", + boundary="b" * 8192, + token="t" * 8192, + pending=tuple(f"{n:03d}" + ("x" * 253) for n in range(100)), + recent=tuple("a" * 64 for _ in range(128)), + loaded=True, + ) + state.continuation() + + +def test_native_dates_preserved_without_inventing_folder_edit_times(): + record = file_record( + {"id": "a", "createdTime": "2026-01-01T00:00:00Z", "modifiedTime": "2026-02-01T00:00:00Z"} + ) + assert record.source_created_at.month == 1 and record.source_updated_at.month == 2 + assert file_record({"id": "b"}).source_updated_at is None + + +async def test_actual_drive_page_source_preserves_factory_selected_node_rejection(): + from types import SimpleNamespace + from unittest.mock import MagicMock + + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + from airweave.domains.sources.token_providers.static import StaticTokenProvider + from airweave.domains.sync_pipeline.factory import SourceBuildResult, SyncFactory + from airweave.platform.configs.config import GoogleDriveConfig + from airweave.platform.sources.google_drive import GoogleDriveSource + + source = await GoogleDriveSource.create( + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + http_client=MagicMock(), + config=GoogleDriveConfig(), + ) + assert isinstance(source, CanonicalPageSource) + result = SourceBuildResult( + source=source, cursor=None, files=None, node_selections=[MagicMock()] + ) + with pytest.raises(ValueError, match="does not support selected nodes"): + SyncFactory._build_stream(None, SimpleNamespace(source=source), result, MagicMock()) diff --git a/backend/tests/unit/platform/sources/records/test_google_drive_content.py b/backend/tests/unit/platform/sources/records/test_google_drive_content.py new file mode 100644 index 0000000000..b4d25f303a --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_google_drive_content.py @@ -0,0 +1,120 @@ +"""Body completeness requires durable bytes matching the observed file version.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from airweave.domains.entities.canonical.requests import BlobReference +from airweave.domains.storage.exceptions import FileSkippedException +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_content import capture_file_content + + +@pytest.fixture +def inputs(): + blob = BlobReference( + key="canonical/test/blobs/sha256/" + "a" * 64, + sha256="a" * 64, + size_bytes=3, + media_type="text/plain", + ) + return { + "files": SimpleNamespace(capture_canonical_url=AsyncMock(return_value=blob)), + "get": AsyncMock(return_value={"version": "7"}), + "client": object(), + "auth": object(), + "logger": Mock(), + } + + +async def test_complete_body_keeps_original_metadata_and_blob(inputs): + record = file_record( + {"id": "file/id", "mimeType": "text/plain", "version": "7", "name": "notes"} + ) + result = await capture_file_content(record, **inputs) + assert result.completeness == "complete" and result.blobs[0].size_bytes == 3 + assert result.blobs[0].filename == "notes" + assert result.payload == record.payload and result.content_hash == "a" * 64 + assert "file%2Fid" in inputs["files"].capture_canonical_url.call_args.kwargs["url"] + + +async def test_changed_version_cannot_claim_complete(inputs): + inputs["get"].return_value = {"version": "8"} + record = file_record({"id": "file", "mimeType": "text/plain", "version": "7"}) + with pytest.raises(ValueError, match="changed during content capture"): + await capture_file_content(record, **inputs) + + +async def test_oversized_body_remains_explicitly_metadata_only(inputs): + inputs["files"].capture_canonical_url.side_effect = FileSkippedException( + reason="too large", filename="file" + ) + record = file_record({"id": "file", "mimeType": "text/plain", "version": "7"}) + result = await capture_file_content(record, **inputs) + assert result.completeness == "metadata_only" and not result.blobs + + +async def test_storage_failure_is_not_silently_a_partial_success(inputs): + inputs["files"].capture_canonical_url.side_effect = ConnectionError("storage unavailable") + record = file_record({"id": "file", "mimeType": "text/plain", "version": "7"}) + with pytest.raises(ConnectionError): + await capture_file_content(record, **inputs) + + +async def test_native_spreadsheet_keeps_existing_export_only_format(inputs): + record = file_record( + { + "id": "doc", + "mimeType": "application/vnd.google-apps.spreadsheet", + "version": "7", + "name": "बजट", + } + ) + result = await capture_file_content(record, **inputs) + assert result.blobs[0].filename == "बजट.xlsx" + call = inputs["files"].capture_canonical_url.call_args.kwargs + assert "/export?" in call["url"] + assert call["media_type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +@pytest.mark.parametrize( + "reason", ["exportSizeLimitExceeded", "insufficientPermissions", "unknown"] +) +async def test_provider_export_limit_is_narrow_and_preserves_native_payload(inputs, reason): + response = httpx.Response( + 403, + json={"error": {"errors": [{"reason": reason}]}}, + request=httpx.Request("GET", "https://www.googleapis.com/drive/v3/files/test/export"), + ) + failure = httpx.HTTPStatusError( + "provider rejection", request=response.request, response=response + ) + inputs["files"].capture_canonical_url.side_effect = failure + record = file_record( + {"id": "doc", "mimeType": "application/vnd.google-apps.spreadsheet", "version": "7"} + ) + if reason != "exportSizeLimitExceeded": + with pytest.raises(httpx.HTTPStatusError): + await capture_file_content(record, **inputs) + return + result = await capture_file_content(record, **inputs) + assert result is record and result.completeness == "metadata_only" and not result.blobs + assert result.payload == record.payload + inputs["get"].assert_not_awaited() + + +def test_unnamed_blob_preserves_historical_descriptor_and_named_blob_roundtrips(): + old = { + "key": "blob", + "sha256": "a" * 64, + "size_bytes": 3, + "media_type": None, + "source_path": None, + } + blob = BlobReference.model_validate(old) + assert blob.model_dump(mode="json") == old + named = blob.model_copy(update={"filename": "../बजट.pdf"}) + assert BlobReference.model_validate_json(named.model_dump_json()).filename == "../बजट.pdf" + assert named.key == blob.key and named.sha256 == blob.sha256 diff --git a/backend/tests/unit/platform/sources/records/test_sheets_content.py b/backend/tests/unit/platform/sources/records/test_sheets_content.py new file mode 100644 index 0000000000..29e81b77fd --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_sheets_content.py @@ -0,0 +1,296 @@ +"""Native structured capture preserves content and truthful bounded coverage.""" + +from copy import deepcopy +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest + +from airweave.adapters.storage.filesystem import FilesystemBackend +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.domains.storage.file_service import FileService +from airweave.platform.sources.records.google_drive import file_record +from airweave.platform.sources.records.google_drive_content import capture_file_content +from airweave.platform.sources.records.sheets_manifest import SHEETS_MIME, GridBounds +from airweave.platform.sources.records.sheets_models import validate_grid + + +def metadata(rows=2, columns=2): + return { + "spreadsheetId": "book", + "properties": {"locale": "en_US", "timeZone": "UTC"}, + "sheets": [ + { + "properties": { + "sheetId": 0, + "title": "A'B", + "hidden": True, + "gridProperties": {"rowCount": rows, "columnCount": columns}, + }, + "merges": [ + { + "sheetId": 0, + "startRowIndex": 0, + "endRowIndex": 1, + "startColumnIndex": 0, + "endColumnIndex": 2, + } + ], + } + ], + } + + +def grid(): + value = metadata() + value["sheets"][0]["data"] = [ + { + "rowData": [ + { + "values": [ + { + "userEnteredValue": {"formulaValue": "=1+1"}, + "effectiveValue": {"numberValue": 2}, + "formattedValue": "2", + "note": "native note", + "effectiveFormat": {"numberFormat": {"type": "NUMBER"}}, + } + ] + } + ] + } + ] + return value + + +async def acquire(tmp_path, monkeypatch, *, partial=False, wrong=False): + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + if partial: + monkeypatch.setattr( + "airweave.platform.sources.records.google_sheets_content.MAX_GRID_REQUESTS", 0 + ) + storage = FilesystemBackend(tmp_path / "store") + files = FileService(uuid4(), storage, sync_id=uuid4()) + calls = [] + + def handler(request): + calls.append(request) + if request.url.path.endswith("/export"): + return httpx.Response(200, content=b"full-xlsx") + assert request.method == "GET" + if "ranges" in request.url.params: + assert request.url.params["ranges"] == "'A''B'!A1:B2" + result = grid() + if wrong: + result["sheets"][0]["properties"]["sheetId"] = 9 + return httpx.Response(200, json=result) + return httpx.Response(200, json=metadata()) + + async def latest(*args, **kwargs): + return {"version": "7"} + + original = file_record({"id": "book", "version": "7", "mimeType": SHEETS_MIME}) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + captured = await capture_file_content( + original, + files=files, + get=latest, + client=client, + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + capture_native_sheets=True, + ) + return captured, storage, calls + + +@pytest.mark.asyncio +async def test_native_formula_values_and_sparse_grid_retained(tmp_path, monkeypatch): + captured, storage, calls = await acquire(tmp_path, monkeypatch) + assert captured.completeness == "complete" + assert len(captured.blobs) == 4 + assert len(calls) == 3 + + +@pytest.mark.asyncio +async def test_budget_preserves_export_and_explicit_unknown_ranges(tmp_path, monkeypatch): + captured, storage, calls = await acquire(tmp_path, monkeypatch, partial=True) + assert captured.completeness == "partial" + assert len(captured.blobs) == 3 + assert len(calls) == 2 + + +@pytest.mark.asyncio +async def test_wrong_native_sheet_rejects_capture(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="different sheets"): + await acquire(tmp_path, monkeypatch, wrong=True) + + +def test_sparse_grid_cannot_escape_requested_bounds(): + bounds = GridBounds(sheet_id=0, start_row=0, end_row=2, start_column=0, end_column=2) + validate_grid(grid(), "book", bounds) + bad = deepcopy(grid()) + bad["sheets"][0]["data"][0]["startRow"] = 2 + with pytest.raises(ValueError, match="outside"): + validate_grid(bad, "book", bounds) + + +@pytest.mark.asyncio +async def test_disabled_native_capture_keeps_export_only_behavior(tmp_path, monkeypatch): + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + files = FileService(uuid4(), FilesystemBackend(tmp_path / "store"), sync_id=uuid4()) + + async def latest(*args, **kwargs): + return {"version": "7"} + + def response(request): + assert request.url.host == "www.googleapis.com" + assert request.url.path.endswith("/export") + return httpx.Response(200, content=b"legacy-xlsx") + + async with httpx.AsyncClient(transport=httpx.MockTransport(response)) as client: + captured = await capture_file_content( + file_record({"id": "book", "version": "7", "mimeType": SHEETS_MIME}), + files=files, + get=latest, + client=client, + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + ) + assert captured.completeness == "complete" + assert len(captured.blobs) == 1 and captured.blobs[0].role is None + + +def test_overlapping_native_cells_are_rejected(): + value = grid() + value["sheets"][0]["data"].append(deepcopy(value["sheets"][0]["data"][0])) + with pytest.raises(ValueError, match="overlapping cells"): + validate_grid( + value, + "book", + GridBounds(sheet_id=0, start_row=0, end_row=2, start_column=0, end_column=2), + ) + + +def test_native_search_groups_sheet_titles_and_preserves_sparse_row_addresses(): + from airweave.domains.entities.canonical.workspace_sheets import ( + CapturedGrid, + CapturedSpreadsheet, + ) + from airweave.platform.sources.records.sheets_manifest import ( + GridPart, + SheetsState, + WorkspaceManifestV2, + ) + from airweave.platform.sources.records.workspace_manifest import ExportState + + parts = tuple( + GridPart( + bounds=GridBounds( + sheet_id=sheet, start_row=0, end_row=100, start_column=0, end_column=1 + ), + blob=str(sheet + 1) * 64, + ) + for sheet in (0, 1) + ) + sheets, grids = [], [] + for index, title in enumerate(("First sheet", "Second sheet")): + properties = { + "sheetId": index, + "title": title, + "gridProperties": {"rowCount": 100, "columnCount": 1}, + } + sheets.append({"properties": properties}) + grids.append( + CapturedGrid( + part=parts[index], + data={ + "spreadsheetId": "book", + "sheets": [ + { + "properties": properties, + "data": [ + { + "startRow": 48, + "rowData": [ + {"values": [{"formattedValue": f"unique-value-{index}"}]} + ], + } + ], + } + ], + }, + ) + ) + captured = CapturedSpreadsheet( + manifest=WorkspaceManifestV2( + file_id="book", + drive_version="7", + export=ExportState(status="unavailable", reason="export_size_limit"), + native=SheetsState(status="complete", metadata_blob="a" * 64, parts=parts), + ), + spreadsheet={"spreadsheetId": "book", "sheets": sheets}, + grids=tuple(grids), + ) + text = captured.text() + assert ( + text.index("First sheet") + < text.index("unique-value-0") + < text.index("Second sheet") + < text.index("unique-value-1") + ) + assert "A49: unique-value-0" in text + assert "'First sheet'!A1:A100" in text + + +@pytest.mark.asyncio +async def test_distinct_blank_ranges_share_bytes_without_losing_coverage(tmp_path, monkeypatch): + from airweave.platform.sources.records.sheets_manifest import parse_sheet_manifest + from airweave.platform.sources.records.sheets_models import validate_partition + + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", lambda _: str(tmp_path / "tmp") + ) + files = FileService(uuid4(), FilesystemBackend(tmp_path / "store"), sync_id=uuid4()) + empty = metadata(rows=200, columns=1) + requested = [] + + async def latest(*args, **kwargs): + return {"version": "7"} + + def response(request): + if request.url.path.endswith("/export"): + return httpx.Response(200, content=b"empty-workbook-export") + if "ranges" in request.url.params: + requested.append(request.url.params["ranges"]) + return httpx.Response(200, json=empty) + + async with httpx.AsyncClient(transport=httpx.MockTransport(response)) as client: + captured = await capture_file_content( + file_record({"id": "book", "version": "7", "mimeType": SHEETS_MIME}), + files=files, + get=latest, + client=client, + auth=StaticTokenProvider("synthetic"), + logger=MagicMock(), + capture_native_sheets=True, + ) + assert requested == ["'A''B'!A1:A100", "'A''B'!A101:A200"] + assert len(captured.blobs) == 3 + marked = next(blob for blob in captured.blobs if blob.role == "representation_manifest") + manifest = parse_sheet_manifest( + await files.storage.read_file(marked.key), + file_id="book", + drive_version="7", + blobs=captured.blobs, + ) + assert len(manifest.native.parts) == 2 + assert {part.blob for part in manifest.native.parts} == {manifest.native.metadata_blob} + assert manifest.native.parts[0].bounds != manifest.native.parts[1].bounds + validate_partition(manifest, empty) diff --git a/backend/tests/unit/platform/sources/records/test_workspace_manifest.py b/backend/tests/unit/platform/sources/records/test_workspace_manifest.py new file mode 100644 index 0000000000..132588a077 --- /dev/null +++ b/backend/tests/unit/platform/sources/records/test_workspace_manifest.py @@ -0,0 +1,113 @@ +"""Representation closure and compatibility, without changing Gmail MIME ownership.""" + +import hashlib +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.requests import ( + BlobReference, + CaptureRecord, + RecordIdentity, +) +from airweave.domains.entities.canonical.store import capture_fingerprint +from airweave.platform.sources.records.workspace_manifest import ( + DocsState, + ExportState, + WorkspaceManifestV1, + canonical_json, + parse_manifest, + validate_document, +) + + +def descriptor(content, **extra): + digest = hashlib.sha256(content).hexdigest() + return BlobReference( + key=f"canonical/sync/blobs/sha256/{digest}", sha256=digest, size_bytes=len(content), **extra + ) + + +def fixture_manifest(): + native = descriptor(b'{"documentId":"doc","tabs":[]}') + model = WorkspaceManifestV1( + file_id="doc", + drive_version="7", + export=ExportState(status="unavailable", reason="export_size_limit"), + native=DocsState(status="complete", document_blob=native.sha256, embedded_media="retained"), + ) + content = canonical_json(model.model_dump(mode="json")) + return content, (native, descriptor(content, role="representation_manifest")) + + +def test_manifest_exact_closure_and_identity(): + content, blobs = fixture_manifest() + assert ( + parse_manifest(content, file_id="doc", drive_version="7", blobs=blobs).native.status + == "complete" + ) + for bad_blobs in (blobs[1:], blobs + (descriptor(b"unreferenced"),), blobs + (blobs[0],)): + with pytest.raises(ValueError): + parse_manifest(content, file_id="doc", drive_version="7", blobs=bad_blobs) + for file_id, version in (("foreign", "7"), ("doc", "8")): + with pytest.raises(ValueError): + parse_manifest(content, file_id=file_id, drive_version=version, blobs=blobs) + + +def test_generic_role_preserves_historical_shape_and_repeated_mime_digest(): + blob = descriptor(b"same", source_path="/payload/parts/0/body") + other = blob.model_copy(update={"source_path": "/payload/parts/1/body"}) + record = CaptureRecord( + identity=RecordIdentity(record_type="message", native_id="one"), + payload={}, + observed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + blobs=(blob, other), + ) + assert "role" not in record.model_dump(mode="json")["blobs"][0] + assert record.model_dump(mode="json")["blobs"][0]["media_type"] is None + assert len(record.blobs) == 2 + assert ( + capture_fingerprint(record) + == "97c6dc0cc6d1376f18aa2f04d7fc66781a3bcfb729dda843dc90ec7037d4bc9d" + ) + with pytest.raises(ValidationError): + descriptor(b"manifest", role="representation_manifest", source_path="/payload") + with pytest.raises(ValidationError): + CaptureRecord.model_validate( + record.model_dump() + | { + "blobs": ( + descriptor(b"first", role="representation_manifest"), + descriptor(b"second", role="representation_manifest"), + ) + } + ) + + +def test_all_tabs_identity_and_unknown_native_fields(): + document = { + "documentId": "doc", + "futureField": {"kept": True}, + "tabs": [ + { + "tabProperties": {"tabId": "a"}, + "documentTab": {}, + "childTabs": [ + { + "tabProperties": {"tabId": "b"}, + "documentTab": {"future": 1}, + } + ], + } + ], + } + validate_document(document, file_id="doc") + assert "revisionId" not in document + with pytest.raises(ValueError): + validate_document(document, file_id="foreign") + with pytest.raises(ValueError): + validate_document({"documentId": "doc", "body": {}}, file_id="doc") + document["tabs"].append(document["tabs"][0]) + with pytest.raises(ValueError): + validate_document(document, file_id="doc") diff --git a/backend/tests/unit/platform/sources/test_attio_capture.py b/backend/tests/unit/platform/sources/test_attio_capture.py new file mode 100644 index 0000000000..a9a19a43c1 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_attio_capture.py @@ -0,0 +1,230 @@ +"""Native Attio capture boundaries; credential-free HTTP fixtures.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import httpx +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceAuthError, SourceError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.platform.configs.config import AttioConfig +from airweave.platform.sources.attio import AttioSource + +WORKSPACE, OBJECT, RECORD, LIST = [str(UUID(int=i)) for i in range(1, 5)] + + +def response(data, status=200): + return httpx.Response( + status, json=data, request=httpx.Request("GET", "https://api.attio.com/v2/notes") + ) + + +def native(kind, value, **fields): + return {"id": {"workspace_id": WORKSPACE, kind + "_id": value}, **fields} + + +def parent(kind, value, container=None): + return SourceRecord( + id=UUID(int=90), + sync_id=UUID(int=91), + identity=RecordIdentity(record_type=kind, native_id=value, container_id=container), + revision=1, + payload={}, + payload_schema_version=1, + capture_hash="0" * 64, + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +async def source(*pages, principal=None): + client = AsyncMock() + client.get.side_effect = [ + response( + principal + if principal is not None + else { + "active": True, + "workspace_id": WORKSPACE, + "token_level": "user", + "authorized_by_workspace_member_id": str(UUID(int=7)), + } + ), + *pages, + ] + result = await AttioSource.create( + auth=ManagedAuthProvider( + api_key="test-only", + connected_account_id="ca_test", + allowed_hosts=frozenset({"api.attio.com"}), + ), + logger=MagicMock(), + http_client=client, + config=AttioConfig(workspace_id=WORKSPACE), + ) + return result, client + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "principal", + [{"active": True}, {"active": True, "workspace_id": str(UUID(int=999))}], +) +async def test_identity_requires_active_matching_workspace(principal): + with pytest.raises(ValueError): + await source(principal=principal) + + +@pytest.mark.asyncio +async def test_native_entries_are_distinct_memberships(): + capture, client = await source() + for number in (10, 11): + list_id = str(UUID(int=number)) + owner = parent("list", list_id) + entry = native( + "entry", + str(UUID(int=number + 100)), + parent_record_id=RECORD, + parent_object="people", + entry_values={"stage": [{"status": "new"}]}, + ) + entry["id"]["list_id"] = list_id + client.post.return_value = response({"data": [entry]}) + page = await capture.capture_page( + capture.child_scope(owner, "entry"), ScanContinuation(), parent=owner, files=MagicMock() + ) + assert page.records[0].payload == entry + assert page.records[0].identity.native_id != RECORD + assert page.records[0].identity.container_id == list_id + assert page.records[0].parent == owner.identity + assert client.post.call_args.kwargs["headers"] == {} + assert all( + capture.capture_cycle_configuration.policy(k) == "discovery_only" + for k in capture.canonical_record_types + ) + + +@pytest.mark.asyncio +async def test_more_than_fifty_notes_resume_and_preserve_native_content(): + obj = response({"data": native("object", OBJECT, api_slug="people")}) + notes = [ + native( + "note", + str(UUID(int=i + 100)), + parent_object="people", + parent_record_id=RECORD, + content_plaintext="body", + content_markdown="**body**", + future={"kept": True}, + ) + for i in range(51) + ] + capture, client = await source( + obj, response({"data": notes[:50]}), obj, response({"data": notes[50:]}) + ) + owner = parent("record", RECORD, OBJECT) + scope = capture.child_scope(owner, "note") + page = await capture.capture_page(scope, ScanContinuation(), files=MagicMock(), parent=owner) + assert not page.final + assert len(page.records) == 50 + # A reconstructed source uses only persisted continuation, not in-memory iteration state. + resumed, resumed_client = await source(obj, response({"data": notes[50:]})) + last = await resumed.capture_page(scope, page.continuation, files=MagicMock(), parent=owner) + assert last.final and last.records[0].payload == notes[-1] + assert resumed_client.get.call_args.kwargs["params"]["offset"] == 50 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code,status", [("merge_in_progress", 404), ("not_found", 404), ("forbidden", 403)] +) +async def test_note_failure_does_not_finalize_or_hide_error(code, status): + capture, client = await source( + response({"data": native("object", OBJECT, api_slug="people")}), + response({"code": code}, status), + ) + owner = parent("record", RECORD, OBJECT) + with pytest.raises(SourceError): + await capture.capture_page( + capture.child_scope(owner, "note"), ScanContinuation(), files=MagicMock(), parent=owner + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fault", ["workspace", "object", "duplicate"]) +async def test_invalid_record_page_rejected_atomically(fault): + capture, client = await source() + raw = native("record", RECORD) + raw["id"]["object_id"] = OBJECT + if fault in {"workspace", "object"}: + raw["id"][fault + "_id"] = str(UUID(int=999)) + client.post.return_value = response({"data": [raw, raw] if fault == "duplicate" else [raw]}) + owner = parent("object", OBJECT) + with pytest.raises(ValueError): + await capture.capture_page( + capture.child_scope(owner, "record"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + + +@pytest.mark.asyncio +async def test_previous_page_overlap_fails_and_absence_never_inferred(): + capture, client = await source() + owner = parent("object", OBJECT) + raw = native("record", RECORD) + raw["id"]["object_id"] = OBJECT + client.post.return_value = response({"data": [raw]}) + with pytest.raises(ValueError, match="repeated identities"): + await capture.capture_page( + capture.child_scope(owner, "record"), + ScanContinuation(value={"offset": 500, "previous_ids": [RECORD]}), + files=MagicMock(), + parent=owner, + ) + with pytest.raises(ValueError, match="cannot confirm absence"): + await capture.confirm_absent(owner) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [429, 500]) +async def test_transient_note_failure_propagates_after_retry_budget(status): + from types import MethodType + + from tenacity import stop_after_attempt + + capture, client = await source( + response({"data": native("object", OBJECT, api_slug="people")}), + response({"code": "transient"}, status), + ) + # Exercise production translation with one attempt, without waiting on fixture failures. + capture._request = MethodType( + AttioSource._request.retry_with(stop=stop_after_attempt(1)), capture + ) + owner = parent("record", RECORD, OBJECT) + with pytest.raises(SourceError): + await capture.capture_page( + capture.child_scope(owner, "note"), ScanContinuation(), files=MagicMock(), parent=owner + ) + + +@pytest.mark.asyncio +async def test_inactive_http200_is_reconnect_error(): + with pytest.raises(SourceAuthError) as error: + await source(principal={"active": False}) + assert error.value.status_code == 200 + assert "reconnect" in str(error.value) diff --git a/backend/tests/unit/platform/sources/test_github_capture.py b/backend/tests/unit/platform/sources/test_github_capture.py new file mode 100644 index 0000000000..71bdfc2819 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_github_capture.py @@ -0,0 +1,673 @@ +"""Synthetic native GitHub fixtures; no provider credentials or writes.""" + +import hashlib +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import httpx +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import InvalidScanContinuation, ScopeAccessLost +from airweave.domains.entities.canonical.requests import BlobReference, CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceError, SourceRateLimitError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.platform.configs.config import GitHubConfig +from airweave.platform.sources.github import GitHubSource +from airweave.platform.sources.github_http import GitHubReader + +REPO = {"id": 100, "owner": {"id": 200}, "full_name": "team/repo", "default_branch": "main"} +ISSUE = { + "id": 300, + "number": 7, + "repository_url": "https://api.github.com/repos/team/repo", + "title": "Original", + "unknown": {"preserved": True}, +} +COMMIT, TREE = "1" * 40, "2" * 40 +BRANCH = {"name": "main", "commit": {"sha": COMMIT}} +CONFIG = { + "expected_user_id": 42, + "repositories": [{"repository_id": 100, "owner_id": 200, "full_name": "team/repo"}], +} + + +def response(value=None, *, status=200, headers=None, content=None): + return httpx.Response( + status, + json=value if content is None else None, + content=content, + headers=headers, + request=httpx.Request("GET", "https://api.github.com/test"), + ) + + +async def source(*responses, config=None): + client = AsyncMock() + client.get.side_effect = responses + + @asynccontextmanager + async def stream(method, url, **kwargs): + original = await client.get(url, **kwargs) + if original.is_stream_consumed: + original = httpx.Response( + original.status_code, + headers=original.headers, + stream=httpx.ByteStream(original.content), + ) + try: + yield original + finally: + await original.aclose() + + client.stream = MagicMock(side_effect=stream) + auth = ManagedAuthProvider( + api_key="synthetic", + connected_account_id="ca_synthetic", + allowed_hosts=frozenset({"api.github.com"}), + ) + capture = await GitHubSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=GitHubConfig.model_validate(config or CONFIG), + ) + return capture, client + + +def saved(record): + return SourceRecord( + id=UUID(int=1), + sync_id=UUID(int=2), + identity=record.identity, + parent=record.parent, + revision=1, + payload=record.payload, + payload_schema_version=record.payload_schema_version, + capture_hash="a" * 64, + content_hash=None, + completeness=record.completeness, + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=record.blobs, + indexed_revision=None, + indexed_pipeline_version=None, + ) + + +async def repository(capture): + return saved( + ( + await capture.capture_page( + CompletedScope(record_type="repository"), ScanContinuation(), files=MagicMock() + ) + ).records[0] + ) + + +@pytest.mark.asyncio +async def test_managed_auth_and_raw_root_fields(): + capture, client = await source(response({**REPO, "private": True, "extra": [1, 2]})) + root = await repository(capture) + assert root.payload["extra"] == [1, 2] + assert root.identity.native_id == "100" + assert "Authorization" not in client.get.call_args.kwargs["headers"] + assert client.get.call_args.kwargs["follow_redirects"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("changed", [{"id": 101}, {"owner": {"id": 201}}]) +async def test_name_reuse_or_owner_transfer_not_captured(changed): + capture, _ = await source(response({**REPO, **changed})) + result = await capture.capture_page( + CompletedScope(record_type="repository"), ScanContinuation(), files=MagicMock() + ) + assert result.final and not result.records + + +@pytest.mark.asyncio +async def test_issue_payload_and_native_pagination(): + next_link = ( + "; rel="next"' + ) + capture, _ = await source( + response(REPO), + response(REPO), + response([ISSUE], headers={"Link": next_link}), + response(REPO), + response([]), + ) + root = await repository(capture) + scope = capture.child_scope(root, "issue") + first = await capture.capture_page(scope, ScanContinuation(), files=MagicMock(), parent=root) + assert first.records[0].payload["issue"] == ISSUE + assert first.records[0].parent == root.identity + assert not first.final + final = await capture.capture_page(scope, first.continuation, files=MagicMock(), parent=root) + assert final.final + + +@pytest.mark.asyncio +async def test_child_route_revalidated_against_repository_name_reuse(): + capture, client = await source(response(REPO), response({**REPO, "id": 999})) + root = await repository(capture) + with pytest.raises(ScopeAccessLost) as error: + await capture.capture_page( + capture.child_scope(root, "issue"), ScanContinuation(), files=MagicMock(), parent=root + ) + assert error.value.removal_reason == "scope_removed" + assert client.get.call_count == 2 + + +@pytest.mark.asyncio +async def test_issue_number_reuse_does_not_read_another_issues_comments(): + capture, client = await source( + response(REPO), + response(REPO), + response([ISSUE]), + response(REPO), + response({**ISSUE, "id": 999}), + ) + root = await repository(capture) + issue = saved( + ( + await capture.capture_page( + capture.child_scope(root, "issue"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + with pytest.raises(ScopeAccessLost): + await capture.capture_page( + capture.child_scope(issue, "comment"), + ScanContinuation(), + files=MagicMock(), + parent=issue, + ) + assert client.get.call_count == 5 + + +@pytest.mark.asyncio +async def test_pinned_code_blob_retained_and_resume_descends_tree(): + content = b"print('hello')\n" + blob_sha = hashlib.sha1(f"blob {len(content)}\0".encode() + content).hexdigest() + child_tree = "3" * 40 + root_tree = { + "sha": TREE, + "truncated": False, + "tree": [{"path": "src", "mode": "040000", "type": "tree", "sha": child_tree}], + } + capture, client = await source( + response(REPO), + response(REPO), + response(BRANCH), + response(REPO), + response({"sha": COMMIT, "tree": {"sha": TREE}}), + response(root_tree), + response(REPO), + response( + { + "sha": child_tree, + "truncated": False, + "tree": [ + { + "path": "hello.py", + "mode": "100644", + "type": "blob", + "sha": blob_sha, + "size": len(content), + } + ], + } + ), + response(content=content), + response(REPO), + response(root_tree), + ) + root = await repository(capture) + branch = saved( + ( + await capture.capture_page( + capture.child_scope(root, "branch"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + scope = capture.child_scope(branch, "file") + files = MagicMock() + files.store_canonical_blob = AsyncMock( + return_value=BlobReference( + key="canonical/test/blob", + sha256=hashlib.sha256(content).hexdigest(), + size_bytes=len(content), + ) + ) + first = await capture.capture_page(scope, ScanContinuation(), files=files, parent=branch) + assert not first.final and not first.records + second = await capture.capture_page(scope, first.continuation, files=files, parent=branch) + assert second.records[0].identity.native_id == "src/hello.py" + assert second.records[0].payload["commit_sha"] == COMMIT + assert second.records[0].blobs + assert files.store_canonical_blob.call_args.args == (content,) + final = await capture.capture_page(scope, second.continuation, files=files, parent=branch) + assert final.final + assert sum("/git/commits/" in str(call.args[0]) for call in client.get.call_args_list) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tree_response,exception", + [ + (response({"sha": TREE, "truncated": True, "tree": []}), ValueError), + (response({}, status=404), InvalidScanContinuation), + ], +) +async def test_unfinished_tree_never_completes(tree_response, exception): + capture, _ = await source( + response(REPO), + response(REPO), + response(BRANCH), + response(REPO), + response({"sha": COMMIT, "tree": {"sha": TREE}}), + tree_response, + response(BRANCH), + ) + root = await repository(capture) + branch = saved( + ( + await capture.capture_page( + capture.child_scope(root, "branch"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + with pytest.raises(exception): + await capture.capture_page( + capture.child_scope(branch, "file"), + ScanContinuation(), + files=MagicMock(), + parent=branch, + ) + + +@pytest.mark.asyncio +async def test_missing_branch_does_not_withdraw_repository_issues(): + capture, _ = await source( + response(REPO), response(REPO), response({}, status=404), response(REPO) + ) + root = await repository(capture) + result = await capture.capture_page( + capture.child_scope(root, "branch"), ScanContinuation(), files=MagicMock(), parent=root + ) + assert result.final and not result.records + + +@pytest.mark.asyncio +async def test_external_redirect_rejected_before_credentials_forwarded(): + capture, client = await source( + response({}, status=301, headers={"Location": "https://evil.invalid/steal"}) + ) + with pytest.raises(SourceError, match="untrusted"): + await repository(capture) + assert client.get.call_count == 1 + + +@pytest.mark.asyncio +async def test_foreign_or_stuck_page_link_rejected(): + capture, _ = await source( + response(REPO), + response(REPO), + response( + [ISSUE], + headers={"Link": '; rel="next"'}, + ), + ) + root = await repository(capture) + with pytest.raises(ValueError, match="out-of-scope"): + await capture.capture_page( + capture.child_scope(root, "issue"), ScanContinuation(), files=MagicMock(), parent=root + ) + + +def test_rate_limit_never_classified_as_scope_loss(): + reader = GitHubReader(MagicMock(), MagicMock()) + with pytest.raises(SourceRateLimitError): + reader._status(response({"message": "API rate limit exceeded"}, status=403)) + + +def test_legacy_and_ambiguous_selections_rejected(): + with pytest.raises(ValidationError, match="Refresh GitHub"): + GitHubConfig(repo_name="team/repo") + with pytest.raises(ValidationError): + GitHubConfig.model_validate({**CONFIG, "repositories": CONFIG["repositories"] * 2}) + + +@pytest.mark.asyncio +async def test_reviews_comments_and_pr_files_keep_original_fields(): + pr_issue = {**ISSUE, "pull_request": {"url": "https://api.github.com/repos/team/repo/pulls/7"}} + pr = { + "id": 400, + "number": 7, + "head": {"sha": "a" * 40}, + "base": {"sha": "b" * 40}, + "changed_files": 1, + } + review = { + "id": 500, + "state": "APPROVED", + "body": "Looks good", + "submitted_at": "2026-09-30T00:00:00Z", + } + file = {"filename": "README.md", "patch": "@@ ...", "status": "modified"} + capture, _ = await source( + response(REPO), + response(REPO), + response([pr_issue]), + response(pr), + response(REPO), + response(pr_issue), + response([review]), + response(REPO), + response(pr_issue), + response(pr), + response([file]), + response(pr), + ) + root = await repository(capture) + issue = saved( + ( + await capture.capture_page( + capture.child_scope(root, "issue"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + assert issue.payload["pull_request_detail"] == pr + result = await capture.capture_page( + capture.child_scope(issue, "review"), ScanContinuation(), files=MagicMock(), parent=issue + ) + assert result.records[0].payload["native"] == review + result = await capture.capture_page( + capture.child_scope(issue, "pull_file"), ScanContinuation(), files=MagicMock(), parent=issue + ) + assert result.records[0].payload["native"] == file + assert result.records[0].completeness == "metadata_only" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("change", ["head", "cap"]) +async def test_pr_diff_changed_or_truncated_does_not_complete(change): + pr_issue = {**ISSUE, "pull_request": {}} + pr = { + "id": 400, + "number": 7, + "head": {"sha": "a" * 40}, + "base": {"sha": "b" * 40}, + "changed_files": 3001 if change == "cap" else 1, + } + after = {**pr, "head": {"sha": "c" * 40}} if change == "head" else pr + capture, _ = await source( + response(REPO), + response(REPO), + response([pr_issue]), + response(pr), + response(REPO), + response(pr_issue), + response(pr), + response([{"filename": "one"}]), + response(after), + ) + root = await repository(capture) + issue = saved( + ( + await capture.capture_page( + capture.child_scope(root, "issue"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + with pytest.raises(InvalidScanContinuation if change == "head" else ValueError): + await capture.capture_page( + capture.child_scope(issue, "pull_file"), + ScanContinuation(), + files=MagicMock(), + parent=issue, + ) + + +@pytest.mark.asyncio +async def test_streamed_blob_enforces_incremental_bound(): + class Chunks(httpx.AsyncByteStream): + def __init__(self): + self.parts = iter((b"small", b"oversized")) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.parts) + except StopIteration: + raise AssertionError("Should stop consuming at the size limit") from None + + native = httpx.Response(200, stream=Chunks()) + capture, _ = await source(native) + with pytest.raises(ValueError, match="byte bound"): + await capture.reader.blob("/repos/team/repo/git/blobs/" + "a" * 40, max_bytes=6) + + +def test_corrupt_pinned_progress_rejected(): + from airweave.platform.sources.github_models import FileProgress + + with pytest.raises(ValidationError, match="complete snapshot"): + FileProgress(initialized=True) + with pytest.raises(ValidationError, match="cannot contain"): + FileProgress(commit_sha=COMMIT) + + +@pytest.mark.asyncio +async def test_composio_capability_binds_only_selected_github_account_and_api_host(): + from airweave.domains.auth_provider.exceptions import AuthProviderConfigError + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "synthetic"}, + config={"auth_config_id": "cfg", "account_id": "ca_synthetic"}, + ) + provider._get_with_auth = AsyncMock( + return_value={ + "id": "ca_synthetic", + "toolkit": {"slug": "github"}, + "auth_config": {"id": "cfg"}, + "status": "ACTIVE", + } + ) + result = await provider.get_auth_result("github", []) + assert result.managed_auth.connected_account_id == "ca_synthetic" + assert result.managed_auth.allowed_hosts == frozenset({"api.github.com"}) + assert provider._get_with_auth.call_args.args[1].endswith("/ca_synthetic") + provider._get_with_auth.return_value["toolkit"] = {"slug": "slack"} + with pytest.raises(AuthProviderConfigError, match="toolkit"): + await provider.get_auth_result("github", []) + + +@pytest.mark.asyncio +async def test_bad_saved_snapshot_fails_before_provider_request(): + capture, client = await source(response(REPO), response(REPO), response(BRANCH)) + root = await repository(capture) + branch = saved( + ( + await capture.capture_page( + capture.child_scope(root, "branch"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + with pytest.raises(ValidationError): + await capture.capture_page( + capture.child_scope(branch, "file"), + ScanContinuation(value={"initialized": True}), + files=MagicMock(), + parent=branch, + ) + assert client.get.call_count == 3 + with pytest.raises(InvalidScanContinuation, match="branch changed"): + await capture.capture_page( + capture.child_scope(branch, "file"), + ScanContinuation( + value={"initialized": True, "ref": "main", "commit_sha": "f" * 40, "tree_sha": TREE} + ), + files=MagicMock(), + parent=branch, + ) + assert client.get.call_count == 3 + + +@pytest.mark.asyncio +async def test_linked_issue_upload_is_explicitly_partial(): + issue = {**ISSUE, "body": "![screenshot](https://github.com/user-attachments/assets/example)"} + capture, _ = await source(response(REPO), response(REPO), response([issue])) + root = await repository(capture) + page = await capture.capture_page( + capture.child_scope(root, "issue"), ScanContinuation(), files=MagicMock(), parent=root + ) + assert page.records[0].completeness == "partial" + assert page.records[0].payload["issue"] == issue + + +@pytest.mark.asyncio +async def test_native_times_feed_existing_canonical_projection_metadata(): + issue = {**ISSUE, "created_at": "2025-01-01T10:00:00Z", "updated_at": "2026-09-30T10:00:00Z"} + capture, _ = await source(response(REPO), response(REPO), response([issue])) + root = await repository(capture) + assert root.source_created_at is None and root.source_updated_at is None + page = await capture.capture_page( + capture.child_scope(root, "issue"), ScanContinuation(), files=MagicMock(), parent=root + ) + item = page.records[0] + assert item.source_created_at.isoformat() == "2025-01-01T10:00:00+00:00" + assert item.source_updated_at.isoformat() == "2026-09-30T10:00:00+00:00" + assert item.source_created_at != item.observed_at + + +@pytest.mark.asyncio +async def test_existing_direct_pat_auth_remains_supported(): + from airweave.domains.sources.token_providers.credential import DirectCredentialProvider + from airweave.platform.configs.auth import GitHubAuthConfig + + client = AsyncMock() + client.get.return_value = response(REPO) + auth = DirectCredentialProvider( + GitHubAuthConfig(personal_access_token="ghp_synthetic_for_test") + ) + reader = GitHubReader(client, auth) + assert await reader.object("/repos/team/repo") == REPO + assert ( + client.get.call_args.kwargs["headers"]["Authorization"] == "Bearer ghp_synthetic_for_test" + ) + + +def test_review_submission_is_not_invented_as_creation_time(): + item = GitHubSource._record( + CompletedScope(record_type="review"), + "1", + {"native": {"id": 1, "submitted_at": "2026-09-30T10:00:00Z"}}, + ) + assert item.source_created_at is None and item.source_updated_at is None + assert item.payload["native"]["submitted_at"] == "2026-09-30T10:00:00Z" + + +@pytest.mark.asyncio +async def test_transferred_issue_withdraws_old_scope_without_reading_new_comments(): + target = "https://api.github.com/repos/other/repo/issues/9" + moved = {**ISSUE, "number": 9, "repository_url": "https://api.github.com/repos/other/repo"} + capture, client = await source( + response(REPO), + response(REPO), + response([ISSUE]), + response(REPO), + response({}, status=301, headers={"Location": target}), + response(moved), + response({}, status=301, headers={"Location": target}), + response(moved), + ) + root = await repository(capture) + issue = saved( + ( + await capture.capture_page( + capture.child_scope(root, "issue"), + ScanContinuation(), + files=MagicMock(), + parent=root, + ) + ).records[0] + ) + with pytest.raises(ScopeAccessLost) as error: + await capture.capture_page( + capture.child_scope(issue, "comment"), + ScanContinuation(), + files=MagicMock(), + parent=issue, + ) + assert error.value.removal_reason == "scope_removed" + await capture.confirm_absent(issue) + assert all("/comments" not in call.args[0] for call in client.get.call_args_list) + + +@pytest.mark.asyncio +async def test_validate_attests_native_user_and_identity_binds_capture_configuration(): + connector, client = await source(response({"id": 42, "login": "renamed-user"})) + await connector.validate() + assert client.get.call_args.args[0] == "https://api.github.com/user" + other, _ = await source(config={**CONFIG, "expected_user_id": 43}) + assert ( + connector.capture_cycle_configuration.fingerprint + != other.capture_cycle_configuration.fingerprint + ) + + +@pytest.mark.asyncio +async def test_validate_rejects_different_native_user_before_repository_reads(): + from airweave.domains.sources.exceptions import SourceAuthError + + connector, client = await source(response({"id": 43, "login": "same-display-label"})) + with pytest.raises(SourceAuthError, match="does not match"): + await connector.validate() + assert client.get.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity", [{}, {"id": None}, {"id": "42"}, {"id": True}, {"id": 0}]) +async def test_validate_rejects_missing_or_malformed_native_identity(identity): + connector, _ = await source(response(identity)) + with pytest.raises(ValidationError): + await connector.validate() + + +@pytest.mark.parametrize("identity", [None, "42", True, 0, -1, 1.5]) +def test_config_requires_strict_positive_native_user_id(identity): + config = {**CONFIG, "expected_user_id": identity} + if identity is None: + config.pop("expected_user_id") + with pytest.raises(ValidationError): + GitHubConfig.model_validate(config) diff --git a/backend/tests/unit/platform/sources/test_linear.py b/backend/tests/unit/platform/sources/test_linear.py index febefbf2be..6e9f6fcaff 100644 --- a/backend/tests/unit/platform/sources/test_linear.py +++ b/backend/tests/unit/platform/sources/test_linear.py @@ -1,270 +1,6 @@ -"""Unit tests for Linear source connector with continuous sync.""" +"""Offline entity timestamp parsing retained after canonical source replacement.""" -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from airweave.platform.configs.config import LinearConfig -from airweave.platform.entities.linear import ( - LinearCommentEntity, - LinearIssueEntity, - LinearProjectEntity, - LinearTeamEntity, - LinearUserEntity, - _parse_dt, -) -from airweave.platform.sources.linear import LinearSource - - -def _mock_auth(token="test-token"): - auth = AsyncMock() - auth.get_token = AsyncMock(return_value=token) - auth.force_refresh = AsyncMock(return_value="refreshed-token") - auth.supports_refresh = True - auth.provider_kind = "oauth" - return auth - - -def _mock_http_client(): - client = AsyncMock() - client.get = AsyncMock() - client.post = AsyncMock() - return client - - -def _mock_logger(): - return MagicMock() - - -def _graphql_response(collection_key: str, nodes: list, has_next_page: bool = False): - """Build a mock Linear GraphQL response.""" - return { - "data": { - collection_key: { - "nodes": nodes, - "pageInfo": {"hasNextPage": has_next_page, "endCursor": "cursor-1"}, - } - } - } - - -TEAM_NODE = { - "id": "team-1", - "name": "Engineering", - "key": "ENG", - "description": "The engineering team", - "color": "#00f", - "icon": None, - "private": False, - "timezone": "UTC", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-06-01T00:00:00Z", - "parent": None, - "issueCount": 42, -} - -PROJECT_NODE = { - "id": "proj-1", - "name": "Q1 Launch", - "slugId": "q1-launch", - "description": "Q1 launch project", - "priority": 1, - "startDate": "2024-01-01", - "targetDate": "2024-03-31", - "state": "started", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-06-01T00:00:00Z", - "completedAt": None, - "startedAt": "2024-01-01T00:00:00Z", - "progress": 0.5, - "teams": {"nodes": [{"id": "team-1", "name": "Engineering"}]}, - "lead": {"name": "Alice"}, -} - -USER_NODE = { - "id": "user-1", - "name": "Alice", - "displayName": "alice", - "email": "alice@example.com", - "avatarUrl": None, - "description": None, - "timezone": "UTC", - "active": True, - "admin": False, - "guest": False, - "lastSeen": "2024-06-01T00:00:00Z", - "statusEmoji": None, - "statusLabel": None, - "statusUntilAt": None, - "createdIssueCount": 10, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-06-01T00:00:00Z", - "teams": {"nodes": [{"id": "team-1", "name": "Engineering", "key": "ENG"}]}, -} - -ISSUE_NODE = { - "id": "issue-1", - "identifier": "ENG-123", - "title": "Fix the bug", - "description": "There is a bug that needs fixing.", - "priority": 2, - "completedAt": None, - "createdAt": "2024-03-01T00:00:00Z", - "updatedAt": "2024-06-15T00:00:00Z", - "dueDate": "2024-07-01", - "archivedAt": None, - "state": {"name": "In Progress"}, - "team": {"id": "team-1", "name": "Engineering"}, - "project": {"id": "proj-1", "name": "Q1 Launch"}, - "assignee": {"name": "Alice"}, - "comments": { - "nodes": [ - { - "id": "comment-1", - "body": "Working on this now.", - "createdAt": "2024-03-02T00:00:00Z", - "updatedAt": "2024-03-02T00:00:00Z", - "user": {"id": "user-1", "name": "Alice"}, - } - ] - }, -} - - -@pytest.fixture -def linear_source(): - """Create a Linear source instance via the v2 ``create()`` contract.""" - - async def _create(): - return await LinearSource.create( - auth=_mock_auth(), - logger=_mock_logger(), - http_client=_mock_http_client(), - config=LinearConfig(), - ) - - return asyncio.run(_create()) - - -# --------------------------------------------------------------------------- -# create -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_create_returns_instance(): - """create() should return a LinearSource with injected deps.""" - auth = _mock_auth() - http = _mock_http_client() - source = await LinearSource.create( - auth=auth, - logger=_mock_logger(), - http_client=http, - config=LinearConfig(), - ) - assert isinstance(source, LinearSource) - assert source.auth is auth - assert source.http_client is http - - -# --------------------------------------------------------------------------- -# full sync (no cursor) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_full_sync_generates_all_entity_types(linear_source): - """On first sync (no cursor), generate_entities yields teams, projects, users, issues, comments.""" - responses = [ - _graphql_response("teams", [TEAM_NODE]), - _graphql_response("projects", [PROJECT_NODE]), - _graphql_response("users", [USER_NODE]), - _graphql_response("issues", [ISSUE_NODE]), - ] - - with patch.object(linear_source, "_post", new_callable=AsyncMock, side_effect=responses): - entities = [] - async for e in linear_source.generate_entities(): - entities.append(e) - - teams = [e for e in entities if isinstance(e, LinearTeamEntity)] - projects = [e for e in entities if isinstance(e, LinearProjectEntity)] - users = [e for e in entities if isinstance(e, LinearUserEntity)] - issues = [e for e in entities if isinstance(e, LinearIssueEntity)] - comments = [e for e in entities if isinstance(e, LinearCommentEntity)] - - assert len(teams) == 1 - assert teams[0].team_id == "team-1" - assert len(projects) == 1 - assert projects[0].project_id == "proj-1" - assert len(users) == 1 - assert users[0].user_id == "user-1" - assert len(issues) == 1 - assert issues[0].issue_id == "issue-1" - assert issues[0].identifier == "ENG-123" - assert len(comments) == 1 - assert comments[0].comment_id == "comment-1" - - -# --------------------------------------------------------------------------- -# incremental sync (with cursor) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_incremental_sync_passes_updated_filter(linear_source): - """Incremental sync should include updatedAt filter in GraphQL queries.""" - mock_cursor = MagicMock() - mock_cursor.data = {"last_synced_at": "2024-06-01T00:00:00Z"} - - captured_queries = [] - responses = [ - _graphql_response("teams", []), - _graphql_response("projects", []), - _graphql_response("users", []), - _graphql_response("issues", []), - ] - - async def capture_post(query): - captured_queries.append(query) - return responses[len(captured_queries) - 1] - - with patch.object(linear_source, "_post", side_effect=capture_post): - async for _ in linear_source.generate_entities(cursor=mock_cursor): - pass - - for query in captured_queries[:4]: - assert "updatedAt" in query - assert "2024-06-01T00:00:00Z" in query - - -@pytest.mark.asyncio -async def test_incremental_sync_updates_cursor(linear_source): - """Incremental sync should update the cursor with the sync start timestamp.""" - mock_cursor = MagicMock() - mock_cursor.data = {"last_synced_at": "2024-06-01T00:00:00Z"} - - responses = [ - _graphql_response("teams", []), - _graphql_response("projects", []), - _graphql_response("users", []), - _graphql_response("issues", []), - ] - - with patch.object(linear_source, "_post", new_callable=AsyncMock, side_effect=responses): - async for _ in linear_source.generate_entities(cursor=mock_cursor): - pass - - mock_cursor.update.assert_called_once() - call_kwargs = mock_cursor.update.call_args[1] - assert "last_synced_at" in call_kwargs - assert call_kwargs["last_synced_at"].endswith("+00:00") - - -# --------------------------------------------------------------------------- -# parse datetime (entity helpers) -# --------------------------------------------------------------------------- +from airweave.platform.entities.linear import _parse_dt def test_parse_dt_valid(): diff --git a/backend/tests/unit/platform/sources/test_linear_capture.py b/backend/tests/unit/platform/sources/test_linear_capture.py new file mode 100644 index 0000000000..48b42060a1 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_linear_capture.py @@ -0,0 +1,531 @@ +"""Credential-free tests of exact Linear originals and whole-scope page authority.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import httpx +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ScopeAccessLost +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceError, SourceRateLimitError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.platform.configs.config import LinearConfig +from airweave.platform.sources.linear import LinearSource + +WORKSPACE = UUID(int=1) +TEAM = UUID(int=2) +ISSUE = UUID(int=3) +COMMENT = UUID(int=4) +STAMP = "2026-09-30T00:00:00Z" + + +def connection(nodes, cursor=None): + return {"nodes": nodes, "pageInfo": {"hasNextPage": cursor is not None, "endCursor": cursor}} + + +def node(identity=ISSUE): + return { + "id": str(identity), + "team": {"id": str(TEAM)}, + "createdAt": STAMP, + "updatedAt": STAMP, + "description": "Original **Markdown**", + "unknown": {"kept": True}, + } + + +def response(data, *, errors=None, status=200, headers=None): + # Child and membership fixtures use the actual exact-ID outer connection. + if isinstance(data, dict) and "issue" in data: + data = {"issues": connection([] if data["issue"] is None else [data["issue"]])} + return httpx.Response( + status, + json={"data": data, **({"errors": errors} if errors else {})}, + headers=headers, + request=httpx.Request("POST", "https://api.linear.app/graphql"), + ) + + +async def source(*pages, workspace=WORKSPACE, config=None): + client = AsyncMock() + client.post.side_effect = [ + response({"organization": {"id": str(workspace)}, "viewer": {"id": str(UUID(int=9))}}), + response({"teams": connection([{"id": str(TEAM)}])}), + *pages, + ] + auth = ManagedAuthProvider( + api_key="test-only", + connected_account_id="ca_test", + allowed_hosts=frozenset({"api.linear.app"}), + ) + result = await LinearSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=config or LinearConfig(workspace_id=WORKSPACE, team_ids=(TEAM,)), + ) + return result, client + + +@pytest.mark.asyncio +async def test_native_payload_pagination_and_managed_auth(): + original = node() + capture, client = await source( + response({"issues": connection([original], "next")}), response({"issues": connection([])}) + ) + first = await capture.capture_page( + CompletedScope(record_type="issue"), ScanContinuation(), files=MagicMock() + ) + assert not first.final + assert first.records[0].payload == original + assert first.records[0].identity.native_id == str(ISSUE) + second = await capture.capture_page( + CompletedScope(record_type="issue"), first.continuation, files=MagicMock() + ) + assert second.final + assert client.post.call_args.kwargs["json"]["variables"]["after"] == "next" + assert client.post.call_args.kwargs["headers"] == {} + + +@pytest.mark.asyncio +async def test_children_preserve_reply_and_independent_edit_without_parent_timestamp(): + original = { + **node(COMMENT), + "issue": {"id": str(ISSUE)}, + "parent": {"id": str(UUID(int=8))}, + "body": "edited", + "updatedAt": "2026-09-30T01:00:00Z", + } + capture, _ = await source( + response( + { + "issue": { + "id": str(ISSUE), + "team": {"id": str(TEAM)}, + "comments": connection([original]), + } + } + ) + ) + page = await capture.capture_page( + CompletedScope(record_type="comment", container_id=str(ISSUE)), + ScanContinuation(), + files=MagicMock(), + ) + assert page.final and page.records[0].payload == original + assert page.records[0].identity.container_id == str(ISSUE) + assert page.records[0].source_updated_at.hour == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field,value", [("issue", {"id": str(UUID(int=99))}), ("issue", None)]) +async def test_wrong_or_null_comment_parent_fails(field, value): + original = {**node(COMMENT), field: value} + capture, _ = await source( + response( + { + "issue": { + "id": str(ISSUE), + "team": {"id": str(TEAM)}, + "comments": connection([original]), + } + } + ) + ) + with pytest.raises(ValueError): + await capture.capture_page( + CompletedScope(record_type="comment", container_id=str(ISSUE)), + ScanContinuation(), + files=MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_partial_graphql_response_never_returns_a_page(): + capture, _ = await source( + response( + {"issues": connection([node()])}, + errors=[{"extensions": {"code": "INTERNAL_SERVER_ERROR"}}], + ) + ) + with pytest.raises(SourceError): + await capture.capture_page( + CompletedScope(record_type="issue"), ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_moved_child_scope_reports_scope_removed(): + capture, _ = await source( + response( + { + "issue": { + "id": str(ISSUE), + "team": {"id": str(UUID(int=99))}, + "comments": connection([]), + } + } + ) + ) + with pytest.raises(ScopeAccessLost) as error: + await capture.capture_page( + CompletedScope(record_type="comment", container_id=str(ISSUE)), + ScanContinuation(), + files=MagicMock(), + ) + assert error.value.removal_reason == "scope_removed" + + +@pytest.mark.asyncio +async def test_exhausted_zero_issue_connection_withdraws_scope_without_deletion_claim(): + capture, _ = await source(response({"issue": None})) + with pytest.raises(ScopeAccessLost) as error: + await capture.capture_page( + CompletedScope(record_type="comment", container_id=str(ISSUE)), + ScanContinuation(), + files=MagicMock(), + ) + + assert error.value.removal_reason == "scope_removed" + + +@pytest.mark.asyncio +async def test_omitted_readable_selected_root_blocks_reconciliation(): + capture, _ = await source( + response({"issue": node()}), + response({"issue": {**node(), "team": {"id": str(UUID(int=99))}}}), + ) + with pytest.raises(ValueError, match="still readable"): + await capture.confirm_absent( + SourceRecord.model_construct( + identity=RecordIdentity(record_type="issue", native_id=str(ISSUE)) + ) + ) + await capture.confirm_absent( + SourceRecord.model_construct( + identity=RecordIdentity(record_type="issue", native_id=str(ISSUE)) + ) + ) + + +@pytest.mark.asyncio +async def test_repeated_cursor_fails_without_final_page(): + capture, _ = await source(response({"issues": connection([node()], "same")})) + with pytest.raises(ValueError, match="non-advancing"): + await capture.capture_page( + CompletedScope(record_type="issue"), + ScanContinuation(value={"after": "same"}), + files=MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_workspace_mismatch_and_unavailable_team_fail_before_capture(): + with pytest.raises(ValueError, match="workspace"): + await source(workspace=UUID(int=99)) + with pytest.raises(ValueError, match="teams are unavailable"): + await source(config=LinearConfig(workspace_id=WORKSPACE, team_ids=(UUID(int=99),))) + + +def test_old_configuration_and_duplicates_fail_explicitly(): + with pytest.raises(ValidationError): + LinearConfig() + with pytest.raises(ValidationError): + LinearConfig(workspace_id=WORKSPACE, team_ids=(TEAM, TEAM)) + + +@pytest.mark.asyncio +async def test_config_fingerprint_and_binary_coverage(): + capture, _ = await source() + original = {**node(), "description": "![image](https://uploads.linear.app/file)"} + record = capture._capture(CompletedScope(record_type="issue"), original) + assert record.completeness == "partial" and not record.blobs + assert record.payload == original + assert capture.canonical_container_parents == {"comment": "issue", "attachment": "issue"} + assert len(capture.capture_cycle_configuration.fingerprint) == 64 + + +@pytest.mark.asyncio +async def test_rate_limit_uses_provider_reset_without_accepting_partial_data(): + capture, _ = await source( + response( + None, + errors=[{"extensions": {"code": "RATELIMITED"}}], + status=400, + headers={"X-RateLimit-Requests-Reset": "9999999999999"}, + ) + ) + # Exercise one attempt without sleeping or retrying an invented rate-limit response. + with pytest.raises(SourceRateLimitError) as error: + await LinearSource._query.__wrapped__(capture, "issues", {}) + assert error.value.retry_after > 60 + + +def test_registry_detects_canonical_class_before_instance_creation(): + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + + assert isinstance(LinearSource, CanonicalPageSource) + + +@pytest.mark.asyncio +async def test_managed_linear_binding_only_allows_graphql_origin(monkeypatch): + from airweave.domains.auth_provider.exceptions import AuthProviderConfigError + from airweave.domains.auth_provider.providers.composio import ComposioAuthProvider + + provider = await ComposioAuthProvider.create( + credentials={"api_key": "test-only"}, + config={"account_id": "ca_test", "auth_config_id": "ac_test"}, + ) + fetch = AsyncMock( + return_value={ + "id": "ca_test", + "toolkit": {"slug": "linear"}, + "status": "ACTIVE", + "auth_config": {"id": "ac_test"}, + } + ) + monkeypatch.setattr(provider, "_get_with_auth", fetch) + result = await provider.get_auth_result("linear", ["access_token"]) + assert result.credentials is None + assert result.managed_auth.allowed_hosts == {"api.linear.app", "uploads.linear.app"} + fetch.return_value["toolkit"]["slug"] = "gmail" + with pytest.raises(AuthProviderConfigError, match="toolkit"): + await provider.get_auth_result("linear", ["access_token"]) + + +@pytest.mark.asyncio +async def test_fresh_instance_resumes_exact_saved_cursor_with_same_scope_fingerprint(): + first_source, _ = await source(response({"issues": connection([node()], "saved-page")})) + first = await first_source.capture_page( + CompletedScope(record_type="issue"), ScanContinuation(), files=MagicMock() + ) + fresh_source, client = await source(response({"issues": connection([])})) + resumed = await fresh_source.capture_page( + CompletedScope(record_type="issue"), + ScanContinuation.model_validate_json(first.continuation.model_dump_json()), + files=MagicMock(), + ) + assert resumed.final + assert client.post.call_args.kwargs["json"]["variables"]["after"] == "saved-page" + assert fresh_source.capture_cycle_configuration == first_source.capture_cycle_configuration + + +@pytest.mark.asyncio +async def test_attachment_page_is_independent_and_does_not_claim_file_bytes(): + attachment = { + **node(UUID(int=5)), + "issue": {"id": str(ISSUE)}, + "url": "https://external.example/document", + "metadata": {"native": True}, + } + capture, _ = await source( + response( + { + "issue": { + "id": str(ISSUE), + "team": {"id": str(TEAM)}, + "attachments": connection([attachment]), + } + } + ) + ) + page = await capture.capture_page( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), + ScanContinuation(), + files=MagicMock(), + ) + assert page.records[0].payload == attachment + assert page.records[0].completeness == "partial" + assert page.records[0].blobs == () + + +@pytest.mark.asyncio +async def test_retained_upload_is_stored_before_reference_without_mutating_original( + monkeypatch, tmp_path +): + import hashlib + from contextlib import asynccontextmanager + + from airweave.domains.storage.file_service import FileService + + @asynccontextmanager + async def stream(*args, **kwargs): + assert args == ("GET", "https://uploads.linear.app/workspace/object") + assert kwargs["follow_redirects"] is False + assert kwargs["headers"] == {} + yield httpx.Response( + 200, + content=b"retained", + headers={"content-type": "application/pdf"}, + request=httpx.Request("GET", args[1]), + ) + + capture, client = await source() + client.stream = MagicMock(side_effect=stream) + storage = AsyncMock() + monkeypatch.setattr(FileService, "_ensure_base_dir", lambda self: None) + files = FileService(sync_job_id=UUID(int=10), sync_id=UUID(int=11), storage_backend=storage) + original = {**node(UUID(int=5)), "url": "https://uploads.linear.app/workspace/object"} + retained = await capture._retain( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), original, files + ) + assert retained.payload == original and retained.completeness == "complete" + ref = retained.blobs[0] + assert ref.sha256 == hashlib.sha256(b"retained").hexdigest() + assert ref.source_path == "/url" + assert ref.key.startswith(f"canonical/{UUID(int=11)}/blobs/sha256/") + storage.write_file.assert_awaited_once_with(ref.key, b"retained") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://external.example/a", + "http://uploads.linear.app/a", + "https://uploads.linear.app.evil.example/a", + "https://user@uploads.linear.app/a", + "https://uploads.linear.app:444/a", + "https://uploads.linear.app/a#fragment", + ], +) +async def test_non_audited_file_url_never_receives_credentials(url): + capture, client = await source() + files = MagicMock() + original = {**node(UUID(int=5)), "url": url} + retained = await capture._retain( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), original, files + ) + assert retained.completeness == "partial" and not retained.blobs + client.stream.assert_not_called() + files.store_canonical_blob.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,content,length", [(302, b"", "0"), (200, b"12345", "1"), (200, b"", "5")] +) +async def test_redirect_or_oversize_never_publishes_a_blob(monkeypatch, status, content, length): + from contextlib import asynccontextmanager + + import airweave.platform.sources.linear as linear_module + + @asynccontextmanager + async def stream(*args, **kwargs): + assert kwargs["follow_redirects"] is False + yield httpx.Response( + status, + content=content, + headers={"content-length": length, "location": "https://external.example/"}, + request=httpx.Request("GET", args[1]), + ) + + capture, client = await source() + client.stream = MagicMock(side_effect=stream) + monkeypatch.setattr(linear_module, "MAX_FILE_SIZE_BYTES", 4) + files = AsyncMock() + original = {**node(UUID(int=5)), "url": "https://uploads.linear.app/a"} + if status == 302: + with pytest.raises(SourceError): + await capture._retain( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), original, files + ) + else: + retained = await capture._retain( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), original, files + ) + assert retained.completeness == "partial" and not retained.blobs + files.store_canonical_blob.assert_not_called() + + +@pytest.mark.asyncio +async def test_file_storage_failure_prevents_page_success(): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def stream(*args, **kwargs): + yield httpx.Response(200, content=b"bytes", request=httpx.Request("GET", args[1])) + + attachment = { + **node(UUID(int=5)), + "issue": {"id": str(ISSUE)}, + "url": "https://uploads.linear.app/a", + } + capture, client = await source( + response( + { + "issue": { + "id": str(ISSUE), + "team": {"id": str(TEAM)}, + "attachments": connection([attachment]), + } + } + ) + ) + client.stream = MagicMock(side_effect=stream) + files = AsyncMock() + files.store_canonical_blob.side_effect = OSError("fixture storage failure") + with pytest.raises(OSError): + await capture.capture_page( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), + ScanContinuation(), + files=files, + ) + + +class _UnreadErrorBody(httpx.AsyncByteStream): + def __init__(self): + self.read_count = 0 + self.closed = False + + async def __aiter__(self): + self.read_count += 1 + raise AssertionError("Error response body must not be drained") + yield b"private error detail" # pragma: no cover + + async def aclose(self): + self.closed = True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [302, 401, 429]) +async def test_unread_stream_error_keeps_status_closes_and_never_stores(status): + from airweave.domains.sources.exceptions import SourceAuthError + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + + body = _UnreadErrorBody() + requests = [] + + def handle(request): + requests.append(request) + return httpx.Response( + status, + stream=body, + headers={"Retry-After": "61", "Location": "https://external.example/"}, + ) + + capture, _ = await source() + logger = MagicMock() + files = AsyncMock() + original = {**node(UUID(int=5)), "url": "https://uploads.linear.app/a?signed=private-value"} + expected = {302: SourceError, 401: SourceAuthError, 429: SourceRateLimitError}[status] + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: + capture._http_client = AirweaveHttpClient( + client, org_id=UUID(int=1), source_short_name="linear", logger=logger + ) + with pytest.raises(expected) as error: + await capture._retain( + CompletedScope(record_type="attachment", container_id=str(ISSUE)), original, files + ) + if status == 429: + assert error.value.retry_after == 61 + if status == 401: + assert error.value.status_code == 401 + assert len(requests) == 1 and body.closed and body.read_count == 0 + files.store_canonical_blob.assert_not_called() + assert "private-value" not in str(logger.debug.call_args_list) + assert "private error detail" not in str(logger.debug.call_args_list) diff --git a/backend/tests/unit/platform/sources/test_notion_capture.py b/backend/tests/unit/platform/sources/test_notion_capture.py new file mode 100644 index 0000000000..b1df3a8410 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_notion_capture.py @@ -0,0 +1,636 @@ +"""Native Notion acquisition fixtures; no provider calls or OAuth grants.""" + +import hashlib +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import httpx +import pytest +from pydantic import ValidationError + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import ScopeAccessLost +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.exceptions import SourceEntityForbiddenError, SourceError +from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider +from airweave.platform.configs.config import NotionConfig +from airweave.platform.sources.notion import API_VERSION, NotionSource + +PAGE, DATA, DATABASE, BLOCK, CHILD = (str(UUID(int=i)) for i in range(1, 6)) +STAMP = "2026-09-30T00:00:00Z" + + +def native(kind="page", identity=PAGE, **fields): + return { + "object": kind, + "id": identity, + "created_time": STAMP, + "last_edited_time": STAMP, + "in_trash": False, + "parent": {"type": "workspace", "workspace": True}, + "unknown": {"retained": True}, + **fields, + } + + +def listing(rows=(), cursor=None, status=None): + result = { + "object": "list", + "results": list(rows), + "has_more": cursor is not None, + "next_cursor": cursor, + } + if status: + result["request_status"] = {"type": "incomplete", "incomplete_reason": status} + return result + + +def response(payload, status=200): + return httpx.Response( + status, json=payload, request=httpx.Request("GET", "https://api.notion.com/v1/test") + ) + + +async def source(*, gets=(), posts=()): + client = AsyncMock() + client.get.side_effect = [response(item) if isinstance(item, dict) else item for item in gets] + client.post.side_effect = [response(item) if isinstance(item, dict) else item for item in posts] + result = await NotionSource.create( + auth=ManagedAuthProvider( + api_key="fixture", + connected_account_id="fixture", + allowed_hosts=frozenset({"api.notion.com"}), + ), + logger=MagicMock(), + http_client=client, + config=NotionConfig(expected_workspace_id=UUID(int=100), expected_bot_id=UUID(int=101)), + ) + return result, client + + +def parent(kind="page", identity=PAGE, **fields): + return SourceRecord.model_construct( + identity=RecordIdentity(record_type=kind, native_id=identity), + payload=native(kind, identity, **fields), + parent=None, + ) + + +@pytest.mark.asyncio +async def test_search_uses_exact_current_root_not_search_snapshot_and_keeps_unknown_fields(): + original = native(title=[{"plain_text": "Current"}]) + connector, client = await source(gets=[original], posts=[listing([native(title=[])])]) + page = await connector.capture_page( + CompletedScope(record_type="page"), ScanContinuation(), files=MagicMock() + ) + assert page.final and page.records[0].payload == original + assert page.records[0].parent is None and page.records[0].completeness == "partial" + assert page.records[0].source_created_at.isoformat() == "2026-09-30T00:00:00+00:00" + assert client.get.call_args.kwargs["headers"] == {"Notion-Version": API_VERSION} + assert client.post.call_args.kwargs["json"]["filter"]["value"] == "page" + assert connector.capture_cycle_configuration.policy("page") == "discovery_with_validation" + + +@pytest.mark.asyncio +async def test_search_rejects_wrong_exact_id_and_capped_discovery(): + connector, _ = await source(gets=[native(identity=CHILD)], posts=[listing([native()])]) + with pytest.raises(ValueError, match="wrong exact"): + await connector.capture_page( + CompletedScope(record_type="page"), ScanContinuation(), files=MagicMock() + ) + connector, _ = await source(posts=[listing(status="query_result_limit_reached")]) + with pytest.raises(SourceError, match="capped"): + await connector.capture_page( + CompletedScope(record_type="page"), ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_omission_refresh_distinguishes_native_unavailability_from_capability_failure(): + connector, _ = await source(gets=[response({"code": "object_not_found"}, 404)]) + result = await connector.refresh_known(parent(), files=MagicMock()) + assert result.kind == "delete" and result.removal_reason == "scope_removed" + connector, _ = await source(gets=[response({"code": "restricted_resource"}, 403)]) + with pytest.raises(SourceEntityForbiddenError): + await connector.refresh_known(parent(), files=MagicMock()) + connector, _ = await source(gets=[native(in_trash=True)]) + result = await connector.refresh_known(parent(), files=MagicMock()) + assert result.removal_reason == "provider_deleted" and result.payload["in_trash"] is True + + +@pytest.mark.asyncio +async def test_query_rows_are_independently_retrieved_roots_and_cap_windows_resume(): + row = native(created_time="2026-09-30T01:00:00Z") + connector, client = await source( + gets=[row, row], posts=[listing([row], status="query_result_limit_reached"), listing([row])] + ) + owner = parent("data_source", DATA) + scope = connector.child_scope(owner, "page") + first = await connector.capture_page(scope, ScanContinuation(), files=MagicMock(), parent=owner) + assert not first.final and first.records == () + assert first.discovered_records[0].parent is None + second = await connector.capture_page( + scope, first.continuation, files=MagicMock(), parent=owner + ) + assert second.final + assert client.post.call_args.kwargs["json"]["filter"]["created_time"]["on_or_after"].startswith( + "2026-09-30T01:" + ) + + +@pytest.mark.asyncio +async def test_query_limit_with_same_timestamp_fails_without_claiming_completion(): + connector, _ = await source( + gets=[native()], posts=[listing([native()], status="query_result_limit_reached")] + ) + owner = parent("data_source", DATA) + with pytest.raises(SourceError, match="cannot be split"): + await connector.capture_page( + connector.child_scope(owner, "page"), + ScanContinuation(value={"window_start": STAMP}), + files=MagicMock(), + parent=owner, + ) + + +@pytest.mark.asyncio +async def test_database_query_scope_does_not_parent_discovered_data_source(): + database = native("database", DATABASE, data_sources=[{"id": DATA, "name": "Rows"}]) + data = native("data_source", DATA, parent={"type": "database_id", "database_id": DATABASE}) + connector, _ = await source(gets=[database, data]) + owner = parent("database", DATABASE) + result = await connector.capture_page( + connector.child_scope(owner, "data_source"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + assert result.final and not result.records + assert result.discovered_records[0].identity.record_type == "data_source" + assert result.discovered_records[0].parent is None + + +@pytest.mark.asyncio +async def test_direct_shared_data_source_does_not_depend_on_reading_database_parent(): + connector, _ = await source(gets=[response({"code": "object_not_found"}, 404)]) + owner = parent("data_source", DATA, parent={"type": "database_id", "database_id": DATABASE}) + result = await connector.capture_page( + connector.child_scope(owner, "database"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + assert result.final and result.discovered_records == () + + +@pytest.mark.asyncio +async def test_child_page_reference_retains_block_and_independent_page_without_double_crawl(): + block = native( + "block", + CHILD, + type="child_page", + has_children=True, + parent={"type": "page_id", "page_id": PAGE}, + ) + connector, _ = await source(gets=[listing([block]), native(identity=CHILD)]) + owner = parent() + result = await connector.capture_page( + connector.child_scope(owner, "block"), ScanContinuation(), files=MagicMock(), parent=owner + ) + assert result.records[0].parent == owner.identity + assert result.records[0].allow_reparent + assert result.discovered_records[0].parent is None + reference = parent( + "block", + CHILD, + **{key: value for key, value in block.items() if key not in {"object", "id"}}, + ) + empty = await connector.capture_page( + connector.child_scope(reference, "block"), + ScanContinuation(), + files=MagicMock(), + parent=reference, + ) + assert empty.final and not empty.records + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", ["wrong_parent", "oversized", "missing_cursor", "repeated_cursor"]) +async def test_invalid_block_pages_fail_before_capture(case): + block = native( + "block", + BLOCK, + type="paragraph", + has_children=False, + parent={"type": "page_id", "page_id": CHILD if case == "wrong_parent" else PAGE}, + ) + data = listing([block] * (26 if case == "oversized" else 1)) + progress = ScanContinuation() + if case in {"missing_cursor", "repeated_cursor"}: + data["has_more"] = True + if case == "repeated_cursor": + data["next_cursor"] = "same" + progress = ScanContinuation(value={"cursor": "same"}) + connector, _ = await source(gets=[data]) + owner = parent() + with pytest.raises((ValueError, ValidationError)): + await connector.capture_page( + connector.child_scope(owner, "block"), progress, files=MagicMock(), parent=owner + ) + + +@pytest.mark.asyncio +async def test_exact_owner_unavailability_withdraws_scope_not_generic_errors(): + connector, _ = await source(gets=[response({"code": "object_not_found"}, 404)]) + owner = parent() + with pytest.raises(ScopeAccessLost) as caught: + await connector.capture_page( + connector.child_scope(owner, "block"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + assert caught.value.removal_reason == "scope_removed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["deleted", "moved", "trashed", "same_owner"]) +async def test_omitted_block_requires_exact_loss_or_move_evidence(state): + owner = RecordIdentity(record_type="page", native_id=PAGE) + block = native( + "block", + BLOCK, + type="paragraph", + has_children=False, + in_trash=state == "trashed", + parent={"type": "page_id", "page_id": CHILD if state == "moved" else PAGE}, + ) + reply = response({"code": "object_not_found"}, 404) if state == "deleted" else block + connector, _ = await source(gets=[reply]) + known = parent("block", BLOCK).model_copy(update={"parent": owner}) + if state == "same_owner": + with pytest.raises(ValueError, match="still readable"): + await connector.confirm_absent(known) + else: + await connector.confirm_absent(known) + + +@pytest.mark.asyncio +async def test_database_inventory_mutation_uses_existing_typed_restart(): + from airweave.domains.entities.canonical.page_source import InvalidScanContinuation + + database = native("database", DATABASE, data_sources=[{"id": DATA}]) + connector, _ = await source(gets=[database]) + owner = parent("database", DATABASE) + with pytest.raises(InvalidScanContinuation): + await connector.capture_page( + connector.child_scope(owner, "data_source"), + ScanContinuation(value={"cursor": "previous-list-digest", "offset": 25}), + files=MagicMock(), + parent=owner, + ) + + +@pytest.mark.asyncio +async def test_trashed_database_stops_enumeration_without_withdrawing_independent_children(): + connector, client = await source( + gets=[native("database", DATABASE, in_trash=True, data_sources=[{"id": DATA}])] + ) + owner = parent("database", DATABASE) + with pytest.raises(ScopeAccessLost) as caught: + await connector.capture_page( + connector.child_scope(owner, "data_source"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + assert caught.value.removal_reason == "scope_removed" + assert client.get.call_count == 1 + + +@pytest.mark.asyncio +async def test_unknown_query_incompleteness_never_uses_limit_recovery(): + connector, _ = await source(posts=[listing(status="unrecognized_future_reason")]) + owner = parent("data_source", DATA) + with pytest.raises(SourceError, match="unsupported reason"): + await connector.capture_page( + connector.child_scope(owner, "page"), + ScanContinuation(), + files=MagicMock(), + parent=owner, + ) + + +@pytest.mark.asyncio +async def test_nonadjacent_cursor_cycle_is_rejected_from_durable_continuation(): + connector, _ = await source( + posts=[listing(cursor="A"), listing(cursor="B"), listing(cursor="A")] + ) + scope = CompletedScope(record_type="page") + first = await connector.capture_page(scope, ScanContinuation(), files=MagicMock()) + second = await connector.capture_page(scope, first.continuation, files=MagicMock()) + with pytest.raises(ValueError, match="cursor cycle"): + await connector.capture_page(scope, second.continuation, files=MagicMock()) + + +@pytest.mark.asyncio +async def test_large_search_advances_with_bounded_recent_cursor_history(): + connector, _ = await source(posts=[listing(cursor=f"page-{i}") for i in range(520)]) + scope = CompletedScope(record_type="page") + continuation = ScanContinuation() + for _ in range(520): + page = await connector.capture_page(scope, continuation, files=MagicMock()) + assert not page.final + # Exercise the durable JSON boundary on every page, not only in-memory state. + continuation = ScanContinuation.model_validate_json(page.continuation.model_dump_json()) + assert len(continuation.value["cursor_hashes"]) <= 512 + hashes = continuation.value["cursor_hashes"] + assert continuation.value["cursor"] == "page-519" + assert hashes[0] == hashlib.sha256(b"page-8").hexdigest() + assert hashes[-1] == hashlib.sha256(b"page-519").hexdigest() + + +@pytest.mark.asyncio +async def test_query_window_resets_recent_cursor_history(): + row = native(created_time="2026-09-30T01:00:00Z") + connector, _ = await source( + gets=[row], + posts=[ + listing(cursor="reusable"), + listing([row], status="query_result_limit_reached"), + listing(cursor="reusable"), + ], + ) + owner = parent("data_source", DATA) + scope = connector.child_scope(owner, "page") + first = await connector.capture_page(scope, ScanContinuation(), files=MagicMock(), parent=owner) + window = await connector.capture_page( + scope, first.continuation, files=MagicMock(), parent=owner + ) + assert not window.final and window.continuation.value["cursor_hashes"] == [] + assert window.continuation.value["cursor"] is None + following = await connector.capture_page( + scope, window.continuation, files=MagicMock(), parent=owner + ) + assert following.continuation.value["cursor"] == "reusable" + + +@pytest.fixture +def property_files(tmp_path, monkeypatch): + from uuid import uuid4 + + from airweave.adapters.storage.filesystem import FilesystemBackend + from airweave.domains.storage.file_service import FileService + + monkeypatch.setattr( + "airweave.domains.storage.file_service.paths.temp_sync_dir", + lambda _: str(tmp_path / "temp"), + ) + return FileService(uuid4(), FilesystemBackend(tmp_path / "blobs"), sync_id=uuid4()) + + +def property_list(identity, kind, values, *, cursor=None, calculation=None): + metadata = {"id": identity, "type": kind, "next_url": None} + if calculation is not None: + metadata[kind] = calculation + return {**listing(values, cursor), "type": "property_item", "property_item": metadata} + + +@pytest.mark.asyncio +async def test_property_archive_preserves_all_native_pages_and_final_rollup(property_files): + import json + + prop = {"id": "roll%2Fup", "type": "rollup", "rollup": {"type": "incomplete"}} + page = native(properties={"Sum": prop}) + first = property_list( + prop["id"], + "rollup", + [], + cursor="next", + calculation={"type": "incomplete", "function": "sum", "incomplete": {}}, + ) + last = property_list( + prop["id"], "rollup", [], calculation={"type": "number", "function": "sum", "number": 13} + ) + connector, client = await source(gets=[page, first, last, page]) + owner = parent() + result = await connector.capture_page( + connector.child_scope(owner, "page_property"), + ScanContinuation(), + files=property_files, + parent=owner, + ) + record = result.records[0] + assert result.final and record.identity.native_id == prop["id"] + assert record.identity.container_id == PAGE and record.parent == owner.identity + assert record.payload["value_status"] == "available" and record.completeness == "partial" + assert record.source_updated_at is None + archive = json.loads(await property_files.storage.read_file(record.blobs[0].key)) + assert archive == { + "format_version": 1, + "page_id": PAGE, + "property_id": prop["id"], + "responses": [first, last], + "notion_version": "2026-03-11", + "page_last_edited_time": STAMP, + } + assert client.get.call_args_list[1].args[0].endswith("properties/roll%2Fup") + assert client.get.call_args_list[2].kwargs["params"]["start_cursor"] == "next" + assert record.payload["response_count"] == 2 + + +@pytest.mark.asyncio +async def test_unsupported_formula_is_explicit_retained_native_result(property_files): + prop = {"id": "calc", "type": "formula"} + page = native(properties={"Formula": prop}) + value = { + **prop, + "object": "property_item", + "formula": {"type": "unsupported", "unsupported": {}}, + } + connector, _ = await source(gets=[page, value, page]) + owner = parent() + result = await connector.capture_page( + connector.child_scope(owner, "page_property"), + ScanContinuation(), + files=property_files, + parent=owner, + ) + assert result.records[0].payload["value_status"] == "unsupported" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "case", + ["wrong_id", "unfinished_rollup", "shape_changed", "provider_error", "edited_page", "overflow"], +) +async def test_property_failure_never_publishes_partial_archive(case, property_files, monkeypatch): + from airweave.domains.entities.canonical.page_source import InvalidScanContinuation + + prop = {"id": "roll", "type": "rollup"} + page = native(properties={"Rollup": prop}) + value = property_list( + "wrong" if case == "wrong_id" else "roll", + "rollup", + [], + calculation={ + "type": "incomplete" if case == "unfinished_rollup" else "number", + "number": 3, + }, + ) + gets = [page, value, page] + expected = (ValueError, SourceError) + if case == "shape_changed": + gets = [ + page, + property_list("roll", "rollup", [], cursor="next", calculation={"type": "incomplete"}), + { + "object": "property_item", + "id": "roll", + "type": "rollup", + "rollup": {"type": "number", "number": 3}, + }, + ] + expected = InvalidScanContinuation + elif case == "provider_error": + gets = [page, response({"code": "restricted_resource"}, 403)] + expected = SourceEntityForbiddenError + elif case == "edited_page": + gets[-1] = {**page, "last_edited_time": "2026-09-30T01:00:00Z"} + expected = InvalidScanContinuation + elif case == "overflow": + monkeypatch.setattr("airweave.platform.sources.notion.MAX_FILE_SIZE_BYTES", 32) + expected = SourceError + connector, _ = await source(gets=gets) + owner = parent() + with pytest.raises(expected): + await connector.capture_page( + connector.child_scope(owner, "page_property"), + ScanContinuation(), + files=property_files, + parent=owner, + ) + assert await property_files.storage.list_files() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity", ["raw/slash", "bad%escape", "id?query=oops", "id#fragment"]) +async def test_unencoded_property_ids_never_enter_native_path(identity, property_files): + page = native(properties={"Field": {"id": identity, "type": "number"}}) + connector, client = await source(gets=[page]) + owner = parent() + with pytest.raises(ValidationError): + await connector.capture_page( + connector.child_scope(owner, "page_property"), + ScanContinuation(), + files=property_files, + parent=owner, + ) + assert client.get.call_count == 1 + + +@pytest.mark.asyncio +async def test_property_archive_roundtrips_unicode_and_empty_value(property_files): + import json + + prop = {"id": "p%25", "type": "rich_text", "rich_text": []} + page = native(properties={'Notes "日本語"': prop}) + value = {**property_list(prop["id"], "rich_text", []), "unknown": '日本語\n"escaped"'} + connector, _ = await source(gets=[page, value, page]) + owner = parent() + result = await connector.capture_page( + connector.child_scope(owner, "page_property"), + ScanContinuation(), + files=property_files, + parent=owner, + ) + record = result.records[0] + archive = json.loads(await property_files.storage.read_file(record.blobs[0].key)) + assert archive["responses"] == [value] + assert record.payload["name"] == 'Notes "日本語"' + assert archive["property_id"] == "p%25" + + +def principal(**overrides): + return { + "object": "user", + "type": "bot", + "id": str(UUID(int=101)), + "bot": {"workspace_id": str(UUID(int=100))}, + **overrides, + } + + +@pytest.mark.asyncio +async def test_validate_exact_workspace_bot_pair_and_fingerprint(): + connector, client = await source(gets=[principal(name="Editable label")]) + await connector.validate() + assert client.get.call_args.args[0] == "https://api.notion.com/v1/users/me" + for field in ("expected_workspace_id", "expected_bot_id"): + changed = await NotionSource.create( + auth=connector.auth, + logger=MagicMock(), + http_client=AsyncMock(), + config=connector.config.model_copy(update={field: UUID(int=102)}), + ) + assert ( + changed.capture_cycle_configuration.fingerprint + != connector.capture_cycle_configuration.fingerprint + ) + assert set(connector.capture_cycle_configuration.completion_policies.values()) == { + "discovery_with_validation" + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [principal(id=str(UUID(int=102))), principal(bot={"workspace_id": str(UUID(int=102))})], +) +async def test_validate_rejects_other_workspace_or_bot(payload): + from airweave.domains.sources.exceptions import SourceAuthError + + connector, client = await source(gets=[payload]) + with pytest.raises(SourceAuthError, match="does not match") as caught: + await connector.validate() + assert caught.value.status_code == 200 + assert client.get.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {}, + principal(object="page"), + principal(type="person"), + principal(id="not-a-uuid"), + principal(bot={}), + principal(bot={"workspace_id": None}), + principal(bot={"workspace_id": "name"}), + ], +) +async def test_validate_rejects_missing_or_malformed_bot_identity(payload): + connector, _ = await source(gets=[payload]) + with pytest.raises(ValidationError): + await connector.validate() + + +@pytest.mark.parametrize( + "config", + [ + {}, + {"expected_workspace_id": str(UUID(int=100))}, + {"expected_workspace_id": "workspace name", "expected_bot_id": str(UUID(int=101))}, + {"expected_workspace_id": str(UUID(int=100)), "expected_bot_id": "bot name"}, + { + "expected_workspace_id": str(UUID(int=100)), + "expected_bot_id": str(UUID(int=101)), + "extra": True, + }, + ], +) +def test_notion_config_requires_only_verified_uuid_pair(config): + with pytest.raises(ValidationError): + NotionConfig.model_validate(config) diff --git a/backend/tests/unit/platform/sources/test_slack_capture.py b/backend/tests/unit/platform/sources/test_slack_capture.py new file mode 100644 index 0000000000..cbd541481f --- /dev/null +++ b/backend/tests/unit/platform/sources/test_slack_capture.py @@ -0,0 +1,336 @@ +"""Slack native page contracts; durable recovery is tested separately against PostgreSQL.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import CompletedScope, RecordIdentity +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackApiError, SlackSource + + +async def source(): + connector = SlackSource( + auth=StaticTokenProvider("token"), logger=MagicMock(), http_client=MagicMock() + ) + connector.slack_config = SlackConfig(expected_team_id="T1", expected_user_id="U1") + with patch.object( + connector, "_get", AsyncMock(return_value={"ok": True, "team_id": "T1", "user_id": "U1"}) + ): + await connector.validate() + return connector + + +@pytest.mark.asyncio +async def test_page_commits_history_before_threads_and_preserves_native_fields(): + connector = await source() + connector._get = AsyncMock( + side_effect=[ + {"channels": [{"id": "C1", "unknown_native_field": "kept"}]}, + { + "messages": [{"ts": "1", "reply_count": 1, "blocks": [{"type": "rich_text"}]}], + "response_metadata": {"next_cursor": "next"}, + "has_more": True, + }, + {"messages": [{"ts": "1.1", "thread_ts": "1", "text": "reply"}]}, + {"messages": [{"ts": "2", "files": [{"id": "F1"}]}]}, + ] + ) + root = await connector.capture_page( + CompletedScope(record_type="channel"), ScanContinuation(), files=MagicMock() + ) + assert root.final and root.records[0].payload["unknown_native_field"] == "kept" + scope = CompletedScope(record_type="message", container_id="C1") + history = await connector.capture_page(scope, ScanContinuation(), files=MagicMock()) + assert history.records[0].payload["blocks"] == [{"type": "rich_text"}] + assert history.continuation.value["pending_threads"] == ["1"] and not history.final + replies = await connector.capture_page(scope, history.continuation, files=MagicMock()) + assert replies.records[0].payload["thread_ts"] == "1" and not replies.final + last = await connector.capture_page(scope, replies.continuation, files=MagicMock()) + assert last.final and last.records[0].completeness == "partial" + calls = connector._get.call_args_list + assert calls[0].args[1]["types"] == "public_channel,private_channel,im,mpim" + assert calls[2].args[0].endswith("conversations.replies") + assert calls[3].args[1]["cursor"] == "next" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "pages", + [ + [{"messages": [], "has_more": True}], + [{"messages": [], "response_metadata": {"next_cursor": "repeat"}}] * 2, + ], +) +async def test_incomplete_pagination_fails(pages): + connector = await source() + connector._get = AsyncMock(side_effect=pages) + progress = ScanContinuation() + with pytest.raises(ValueError): + for _ in pages: + result = await connector.capture_page( + CompletedScope(record_type="message", container_id="C1"), + progress, + files=MagicMock(), + ) + progress = result.continuation + + +@pytest.mark.asyncio +async def test_prior_channel_loss_requires_explicit_provider_confirmation(): + connector = await source() + connector._get = AsyncMock(side_effect=SlackApiError("channel_not_found")) + await connector.confirm_absent( + SourceRecord.model_construct(identity=RecordIdentity(record_type="channel", native_id="C1")) + ) + connector._get = AsyncMock(return_value={"channel": {"id": "C1"}}) + with pytest.raises(ValueError, match="omitted"): + await connector.confirm_absent( + SourceRecord.model_construct( + identity=RecordIdentity(record_type="channel", native_id="C1") + ) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["json", "http", "proxy"]) +async def test_rate_limit_honors_entire_wait_and_preserves_proxy_type(kind): + import httpx + + from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError + + connector = await source() + request = httpx.Request("GET", "https://slack.com/api/conversations.list") + first = ( + AuthProviderRateLimitError(provider_name="composio", retry_after=240) + if kind == "proxy" + else httpx.Response( + 429 if kind == "http" else 200, + headers={"Retry-After": "240"}, + json={"ok": False, "error": "ratelimited"}, + request=request, + ) + ) + connector.http_client.get = AsyncMock( + side_effect=[first, httpx.Response(200, json={"ok": True}, request=request)] + ) + waits = AsyncMock() + get = connector._get.retry_with(sleep=waits) + assert await get(connector, str(request.url)) == {"ok": True} + assert float(waits.call_args.args[0]) == 240 + assert connector.http_client.get.await_count == 2 + + +@pytest.mark.asyncio +async def test_proxy_rate_limit_stops_after_five_attempts(): + from airweave.domains.auth_provider.exceptions import AuthProviderRateLimitError + + connector = await source() + connector.http_client.get = AsyncMock( + side_effect=AuthProviderRateLimitError(provider_name="composio", retry_after=240) + ) + waits = AsyncMock() + with pytest.raises(AuthProviderRateLimitError): + await connector._get.retry_with(sleep=waits)(connector, "https://slack.com/api/test") + assert connector.http_client.get.await_count == 5 + assert waits.await_count == 4 + assert all(float(call.args[0]) == 240 for call in waits.call_args_list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code, expected", + [ + ("thread_not_found", "restart"), + ("channel_not_found", "access"), + ("not_in_channel", "access"), + ("missing_scope", "error"), + ("internal_error", "error"), + ], +) +async def test_queued_thread_failure_preserves_error_meaning(code, expected): + from airweave.domains.entities.canonical.page_source import ( + InvalidScanContinuation, + ScopeAccessLost, + ) + + connector = await source() + connector._get = AsyncMock(side_effect=SlackApiError(code)) + error = {"restart": InvalidScanContinuation, "access": ScopeAccessLost, "error": SlackApiError}[ + expected + ] + with pytest.raises(error): + await connector.capture_page( + CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(value={"pending_threads": ["1"]}), + files=MagicMock(), + ) + assert connector._get.await_count == 1 + + +@pytest.mark.asyncio +async def test_thread_not_found_outside_reply_fetch_does_not_invalidate_inventory(): + connector = await source() + connector._get = AsyncMock(side_effect=SlackApiError("thread_not_found")) + with pytest.raises(SlackApiError): + await connector.capture_page( + CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(), + files=MagicMock(), + ) + + +@pytest.mark.parametrize( + "native,created,updated", + [ + ( + {"ts": "1355517523.000005", "edited": {"ts": "1355517536.000001"}}, + "2012-12-14T20:38:43.000005+00:00", + "2012-12-14T20:38:56.000001+00:00", + ), + ( + {"ts": "1355517523.000005", "latest_reply": "1855517536.999999"}, + "2012-12-14T20:38:43.000005+00:00", + None, + ), + ({"ts": "invalid", "edited": {"ts": "NaN"}}, None, None), + ({"ts": "999999999999.123456", "edited": {"ts": True}}, None, None), + ], +) +def test_message_native_dates_preserve_precision_and_unknowns(native, created, updated): + record = SlackSource._capture_message(native, "C1") + assert record.payload == native + assert (record.source_created_at.isoformat() if record.source_created_at else None) == created + assert (record.source_updated_at.isoformat() if record.source_updated_at else None) == updated + + +@pytest.mark.asyncio +async def test_conversation_dates_use_distinct_native_units(): + connector = await source() + connector._get = AsyncMock( + return_value={ + "channels": [ + {"id": "C1", "created": 1449252889, "updated": 1689965803820}, + {"id": "C2", "created": True, "updated": "1689965803820"}, + {"id": "C3"}, + ] + } + ) + result = await connector.capture_page( + CompletedScope(record_type="channel"), ScanContinuation(), files=MagicMock() + ) + first, malformed, absent = result.records + assert first.source_created_at.isoformat() == "2015-12-04T18:14:49+00:00" + assert first.source_updated_at.isoformat() == "2023-07-21T18:56:43.820000+00:00" + assert malformed.source_created_at is malformed.source_updated_at is None + assert absent.source_created_at is absent.source_updated_at is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reply", [False, True]) +async def test_message_omission_reads_exact_native_message_and_never_confirms_empty(reply): + connector = await source() + payload = {"ts": "2", **({"thread_ts": "1"} if reply else {})} + record = SourceRecord.model_construct( + identity=RecordIdentity(record_type="message", native_id="2", container_id="C1"), + parent=RecordIdentity(record_type="channel", native_id="C1"), + payload=payload, + ) + connector._get = AsyncMock(return_value={"messages": [payload]}) + with pytest.raises(ValueError, match="accessible prior message"): + await connector.confirm_absent(record) + operation = "conversations.replies" if reply else "conversations.history" + connector._get.assert_awaited_once_with( + f"https://slack.com/api/{operation}", + { + "channel": "C1", + "oldest": "2", + "latest": "2", + "inclusive": "true", + "limit": 1, + **({"ts": "1"} if reply else {}), + }, + ) + connector._get = AsyncMock(return_value={"messages": []}) + with pytest.raises(ValueError, match="remains unconfirmed"): + await connector.confirm_absent(record) + connector._get = AsyncMock(side_effect=SlackApiError("channel_not_found")) + with pytest.raises(SlackApiError): + await connector.confirm_absent(record) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reply", [False, True]) +async def test_exact_attachment_owner_refresh_preserves_native_inventory_and_parent(reply): + connector = await source() + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=True + ) + identity = RecordIdentity(record_type="message", native_id="2", container_id="C1") + parent = RecordIdentity(record_type="channel", native_id="C1") + payload = {"ts": "2", **({"thread_ts": "1"} if reply else {})} + record = SourceRecord.model_construct(identity=identity, parent=parent, payload=payload) + fresh = {**payload, "files": [{"id": "F2"}], "unknown_native_field": "retained"} + connector._get = AsyncMock(return_value={"messages": [fresh]}) + result = await connector.refresh_known(record, files=MagicMock()) + assert result.identity == identity and result.parent == parent + assert result.payload == fresh and result.payload_schema_version == 2 + assert result.descendant_visibility_fields == ("files",) + assert connector.capture_cycle_configuration.exact_parent_validation == ("message",) + operation = "conversations.replies" if reply else "conversations.history" + assert connector._get.call_args.args[0].endswith(operation) + assert connector._get.call_args.args[1]["oldest"] == "2" + assert connector._get.call_args.args[1]["latest"] == "2" + for messages, error in [ + ([], "remains unconfirmed"), + ([{**fresh, "thread_ts": "wrong"}], "thread identity"), + ([fresh, fresh], "duplicate identities"), + ]: + connector._get = AsyncMock(return_value={"messages": messages}) + with pytest.raises(ValueError, match=error): + await connector.refresh_known(record, files=MagicMock()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("native_files", [None, [], [{"id": "F1"}]]) +async def test_fresh_message_page_declares_exact_file_inventory_scope(native_files): + connector = await source() + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=True + ) + native = {"ts": "1", "text": "Retained unchanged"} + if native_files is not None: + native["files"] = native_files + connector._get = AsyncMock(return_value={"messages": [native]}) + page = await connector.capture_page( + CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(), + files=MagicMock(), + ) + assert page.records[0].payload == native + (declaration,) = page.child_scope_observations + owner = SourceRecord.model_construct(identity=page.records[0].identity) + assert declaration.scope == connector.child_scope(owner, "file") + assert declaration.continuation == ScanContinuation() + assert page.records[0].payload_schema_version == 2 + connector._get.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_invalid_attachment_inventory_cannot_emit_fresh_scope_receipt(): + connector = await source() + connector.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=True + ) + connector._get = AsyncMock( + return_value={"messages": [{"ts": "1", "files": [{"id": "F1"}, {"id": "F1"}]}]} + ) + with pytest.raises(ValueError, match="duplicate file identities"): + await connector.capture_page( + CompletedScope(record_type="message", container_id="C1"), + ScanContinuation(), + files=MagicMock(), + ) diff --git a/backend/tests/unit/platform/sources/test_slack_content.py b/backend/tests/unit/platform/sources/test_slack_content.py new file mode 100644 index 0000000000..b1ae8e48a4 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_slack_content.py @@ -0,0 +1,218 @@ +"""One-file capture interpretation and retained-inventory continuation boundaries.""" + +import hashlib +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import httpx +import pytest + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.page_source import InvalidScanContinuation +from airweave.domains.entities.canonical.requests import ( + BlobReference, + CaptureRecord, + RecordIdentity, +) +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.domains.entities.canonical.slack_files import SlackFileManifest +from airweave.domains.sources.exceptions import SourceError +from airweave.domains.sources.token_providers.static import StaticTokenProvider +from airweave.platform.configs.config import SlackConfig +from airweave.platform.sources.slack import SlackApiError, SlackPrincipal, SlackSource +from airweave.platform.sources.slack_content import capture_slack_file + + +def connector(): + result = SlackSource( + auth=StaticTokenProvider("fixture"), logger=MagicMock(), http_client=MagicMock() + ) + result.slack_config = SlackConfig( + expected_team_id="T1", expected_user_id="U1", capture_files=True + ) + result._verified_principal = SlackPrincipal(ok=True, team_id="T1", user_id="U1") + return result + + +def native(identity="F1", **changes): + return { + "id": identity, + "size": 3, + "mimetype": "text/plain", + "url_private": f"https://files.slack.com/{identity}", + **changes, + } + + +def original(payload): + return CaptureRecord( + identity=RecordIdentity( + record_type="file", native_id=payload["id"], container_id="parent:fixture" + ), + payload=payload, + observed_at=datetime.now(timezone.utc), + ) + + +def storage(): + files = MagicMock(MAX_FILE_SIZE_BYTES=200 * 1024 * 1024) + saved = [] + + async def store(content, *, media_type=None): + saved.append(content) + digest = hashlib.sha256(content).hexdigest() + return BlobReference( + key=digest, sha256=digest, size_bytes=len(content), media_type=media_type + ) + + files.store_canonical_blob = AsyncMock(side_effect=store) + files.capture_canonical_url = AsyncMock( + return_value=BlobReference(key="original", sha256="a" * 64, size_bytes=3) + ) + return files, saved + + +@pytest.mark.asyncio +async def test_file_enrichment_preserves_raw_child_and_root_blob(): + source = connector() + files, saved = storage() + source._get = AsyncMock(return_value={"file": native(name="note.txt")}) + item = original({"id": "F1"}) + result = await capture_slack_file(source, item, files) + assert result.payload == {"id": "F1"} and result.completeness == "complete" + assert result.blobs[0].filename == "note.txt" + assert result.blobs[1].filename is None + assert [b.source_path for b in result.blobs] == ["", None] + manifest = SlackFileManifest.model_validate_json(saved[-1]) + assert manifest.files[0].file["name"] == "note.txt" + assert manifest.files[0].outcome == "captured" + assert files.capture_canonical_url.call_args.kwargs["follow_redirects"] is False + assert files.capture_canonical_url.call_args.kwargs["expected_media_type"] == "text/plain" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [302, 401, 429, 503]) +async def test_nonterminal_download_failure_cannot_create_manifest(status): + source = connector() + files, saved = storage() + files.capture_canonical_url.side_effect = httpx.HTTPStatusError( + "private", + request=httpx.Request("GET", "https://files.slack.com/private"), + response=httpx.Response(status), + ) + with pytest.raises(SourceError): + await capture_slack_file(source, original(native()), files) + assert not saved + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "http://files.slack.com/x", + "https://files.slack.com.evil/x", + "https://token@slack.com/x", + "https://slack.com:444/x", + ], +) +async def test_untrusted_original_never_downloads(url): + source = connector() + files, _ = storage() + with pytest.raises(ValueError, match="permitted origin"): + await capture_slack_file(source, original(native(url_private=url)), files) + files.capture_canonical_url.assert_not_called() + + +@pytest.mark.asyncio +async def test_explicit_missing_and_denied_metadata_vs_configuration_failure(): + source = connector() + files, saved = storage() + item = original({"id": "F1"}) + source._get = AsyncMock(side_effect=SlackApiError("not_visible")) + result = await capture_slack_file(source, item, files) + assert result.completeness == "partial" + assert SlackFileManifest.model_validate_json(saved[-1]).files[0].reason == "access_denied" + source._get = AsyncMock( + return_value={"file": {"id": "F1", "thumb_360": "https://files.slack.com/thumb"}} + ) + await capture_slack_file(source, item, files) + assert SlackFileManifest.model_validate_json(saved[-1]).files[0].reason == "missing_metadata" + files.capture_canonical_url.assert_not_called() + source._get = AsyncMock(side_effect=SlackApiError("missing_scope")) + with pytest.raises(SlackApiError): + await capture_slack_file(source, item, files) + + +@pytest.mark.asyncio +async def test_file_size_and_enriched_identity_must_match(): + source = connector() + files, saved = storage() + with pytest.raises(ValueError, match="size changed"): + await capture_slack_file(source, original(native(size=4)), files) + source._get = AsyncMock(return_value={"file": native("F2")}) + with pytest.raises(ValueError, match="identity changed"): + await capture_slack_file(source, original({"id": "F1"}), files) + assert not saved + + +@pytest.mark.asyncio +async def test_child_continuation_uses_retained_inventory_and_exact_parent(): + source = connector() + files, _ = storage() + parent = SourceRecord.model_construct( + id=uuid4(), + identity=RecordIdentity(record_type="message", native_id="1", container_id="C1"), + payload_schema_version=2, + payload={"files": [native(), native("F2")]}, + content_access="available", + deleted_at=None, + ) + scope = source.child_scope(parent, "file") + first = await source.capture_page(scope, ScanContinuation(), files=files, parent=parent) + assert not first.final and first.records[0].parent == parent.identity + second = await source.capture_page(scope, first.continuation, files=files, parent=parent) + assert second.final and second.records[0].identity.native_id == "F2" + changed = parent.model_copy(update={"payload": {"files": [native("F2")]}}) + with pytest.raises(InvalidScanContinuation): + await source.capture_page(scope, first.continuation, files=files, parent=changed) + other = parent.model_copy( + update={"identity": parent.identity.model_copy(update={"container_id": "C2"})} + ) + with pytest.raises(ValueError, match="current retained message"): + await source.capture_page(scope, ScanContinuation(), files=files, parent=other) + assert files.capture_canonical_url.await_count == 2 + + +def test_disabled_topology_and_fingerprint_stay_legacy(): + import json + + source = connector() + assert source.canonical_container_parents["file"] == "message" + enabled = source.capture_cycle_configuration.fingerprint + source.slack_config = source.slack_config.model_copy(update={"capture_files": False}) + expected = hashlib.sha256( + json.dumps({"version": 2, "team_id": "T1", "user_id": "U1"}, sort_keys=True).encode() + ).hexdigest() + assert source.capture_cycle_configuration.fingerprint == expected != enabled + assert source.canonical_record_types == ("channel", "message") + assert SlackConfig().capture_files is False + assert "file" in SlackSource.canonical_record_types + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "metadata,reason", + [ + (native(is_external=True), "unsupported_external"), + (native(size=201 * 1024 * 1024), "oversized"), + ], +) +async def test_terminal_file_omission_is_explicit_child_state(metadata, reason): + source = connector() + files, saved = storage() + result = await capture_slack_file(source, original(metadata), files) + assert result.completeness == "partial" + assert len(result.blobs) == 1 and result.blobs[0].role == "representation_manifest" + assert SlackFileManifest.model_validate_json(saved[-1]).files[0].reason == reason + files.capture_canonical_url.assert_not_called() diff --git a/backend/tests/unit/platform/sources/test_stripe_capture.py b/backend/tests/unit/platform/sources/test_stripe_capture.py new file mode 100644 index 0000000000..1f9d1a3349 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_stripe_capture.py @@ -0,0 +1,275 @@ +"""Stripe provider interpretation only; no live account or SQL qualification.""" + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.entities.canonical.scan_models import ScanContinuation +from airweave.platform.sources.stripe_capture import ( + StripeCapture, + StripeCaptureConfig, + StripeCaptureError, +) + + +def config(**overrides): + return StripeCaptureConfig( + **{ + "expected_account_id": "acct_selected", + "connected_account_id": "acct_selected", + "livemode": False, + "api_version": "2025-06-30.basil", + **overrides, + } + ) + + +def identity(): + return [ + {"object": "account", "id": "acct_selected"}, + {"object": "balance", "livemode": False, "available": []}, + ] + + +async def page(source, kind, continuation=None): + return await source.capture_page( + CompletedScope(record_type=kind), continuation or ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_all_resource_families_preserve_native_json_and_context(): + read = AsyncMock(side_effect=identity()) + source = await StripeCapture.create(read, config()) + assert len(source.canonical_record_types) == 11 + assert all( + source.capture_cycle_configuration.policy(kind) == "discovery_only" + for kind in source.canonical_record_types + ) + for kind in source.canonical_record_types: + native = {"object": kind, "livemode": False, "unknown": {"nested": [1, None]}} + if kind != "balance": + native.update(id="obj_one", created=123) + if kind == "payment_method": + native.update(type="us_bank_account", us_bank_account={"last4": "6789"}) + read.side_effect = None + read.return_value = ( + native + if kind == "balance" + else { + "object": "list", + "data": [native], + "has_more": False, + "url": "https://untrusted.invalid/do-not-follow", + } + ) + result = await page(source, kind) + assert result.final and result.records[0].payload == native + assert result.records[0].completeness == "partial" + assert result.records[0].parent is None + assert result.records[0].kind == "upsert" + assert result.records[0].source_updated_at is None + if kind == "subscription": + assert read.call_args.kwargs["params"]["status"] == "all" + if kind == "payment_method": + assert "type" not in read.call_args.kwargs["params"] + assert result.records[0].payload["type"] == "us_bank_account" + for call in read.call_args_list: + assert call.kwargs["headers"] == { + "Stripe-Version": "2025-06-30.basil", + "Stripe-Account": "acct_selected", + } + assert call.args[0].startswith("/v1/") + + +@pytest.mark.asyncio +async def test_failed_page_retries_same_cursor_and_binding_cannot_change(): + raw = {"id": "cus_one", "object": "customer", "livemode": False} + read = AsyncMock(side_effect=identity() + [{"object": "list", "data": [raw], "has_more": True}]) + source = await StripeCapture.create(read, config()) + first = await page(source, "customer") + failure = RuntimeError("transport failed") + read.side_effect = failure + with pytest.raises(RuntimeError) as error: + await page(source, "customer", first.continuation) + assert error.value is failure + assert read.call_args.kwargs["params"]["starting_after"] == "cus_one" + read.side_effect = None + read.return_value = {"object": "list", "data": [], "has_more": False} + final = await page(source, "customer", first.continuation) + assert final.final and final.records == () + calls = read.await_count + with pytest.raises(StripeCaptureError, match="binding"): + await page(source, "event", first.continuation) + other = await StripeCapture.create( + AsyncMock(side_effect=identity()), config(api_version="2024-06-20") + ) + with pytest.raises(StripeCaptureError, match="binding"): + await page(other, "customer", first.continuation) + assert read.await_count == calls + + +@pytest.mark.asyncio +async def test_failed_reattest_resets_prior_identity_and_mode(): + read = AsyncMock(side_effect=identity()) + source = await StripeCapture.create(read, config()) + read.side_effect = None + read.return_value = {"object": "account", "id": "acct_other"} + with pytest.raises(StripeCaptureError, match="account"): + await source.verify_principal() + calls = read.await_count + with pytest.raises(StripeCaptureError, match="not attested"): + await page(source, "customer") + assert read.await_count == calls + read.side_effect = [identity()[0], {"object": "balance", "livemode": True}] + with pytest.raises(StripeCaptureError, match="mode"): + await source.verify_principal() + + +@pytest.mark.asyncio +async def test_malformed_page_never_becomes_empty_success(): + read = AsyncMock(side_effect=identity()) + source = await StripeCapture.create(read, config()) + read.side_effect = None + obj = {"id": "cus_one", "object": "customer", "livemode": False} + for response in ( + {"object": "list", "data": [], "has_more": True}, + {"object": "list", "data": [obj, obj], "has_more": False}, + {"object": "list", "data": [{**obj, "object": "event"}], "has_more": False}, + {"object": "list", "data": [{**obj, "livemode": True}], "has_more": False}, + {"object": "list", "data": [{**obj, "livemode": None}], "has_more": False}, + {"object": "list", "data": [], "has_more": "false"}, + {"object": "list", "has_more": False}, + ): + read.return_value = response + with pytest.raises(StripeCaptureError): + await page(source, "customer") + + +@pytest.mark.asyncio +async def test_event_snapshot_never_updates_embedded_object_or_confirms_absence(): + event = { + "id": "evt_one", + "object": "event", + "api_version": "2019-02-19", + "created": 123, + "livemode": False, + "type": "customer.deleted", + "data": {"object": {"id": "cus_deleted", "object": "customer", "deleted": True}}, + } + read = AsyncMock( + side_effect=identity() + [{"object": "list", "data": [event], "has_more": False}] + ) + source = await StripeCapture.create(read, config()) + result = await page(source, "event") + assert len(result.records) == 1 + assert result.records[0].identity.native_id == "evt_one" + assert result.records[0].payload == event + assert result.records[0].kind == "upsert" + with pytest.raises(StripeCaptureError, match="cannot confirm absence"): + await source.confirm_absent(MagicMock()) + + +@pytest.mark.asyncio +async def test_managed_source_wires_capture_without_exposing_credentials(): + import httpx + + from airweave.domains.sources.exceptions import SourceEntityForbiddenError, SourceError + from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider + from airweave.platform.configs.config import StripeConfig + from airweave.platform.sources.stripe import StripeSource + + auth = ManagedAuthProvider( + api_key="test-only", + connected_account_id="ca_selected", + allowed_hosts=frozenset({"api.stripe.com"}), + ) + client = AsyncMock() + client.get.side_effect = [httpx.Response(200, json=value) for value in identity()] + + @asynccontextmanager + async def stream(method, url, **kwargs): + assert method == "GET" and kwargs["follow_redirects"] is False + yield await client.get(url, **kwargs) + + client.stream = MagicMock(side_effect=stream) + source = await StripeSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=StripeConfig(original_capture=config()), + ) + assert isinstance(source.capture_page_source, StripeCapture) + for call in client.get.call_args_list: + assert call.args[0].startswith("https://api.stripe.com/v1/") + assert call.kwargs["headers"] == { + "Stripe-Version": "2025-06-30.basil", + "Stripe-Account": "acct_selected", + } + client.get.side_effect = None + client.get.return_value = httpx.Response(403, json={"error": {"message": "private-secret"}}) + with pytest.raises(SourceEntityForbiddenError) as exc: + await page(source.capture_page_source, "customer") + assert "private-secret" not in str(exc.value) + with pytest.raises(SourceError, match="explicitly bound"): + await StripeSource.create( + auth=auth, + logger=MagicMock(), + http_client=client, + config=StripeConfig(), + ) + + +@pytest.mark.parametrize("status", [200, 403]) +def test_stream_limit_and_error_status_close_response_without_reading_private_tail(status): + import asyncio + + # Own this loop so the standard runner also finalizes HTTPX's nested async + # generators before closing it; no timing sleep or warning suppression. + asyncio.run(_stream_failure_scenario(status)) + + +async def _stream_failure_scenario(status): + from uuid import uuid4 + + import httpx + + from airweave.domains.sources.exceptions import SourceError + from airweave.domains.sources.token_providers.protocol import ManagedAuthProvider + from airweave.platform.http_client.airweave_client import AirweaveHttpClient + from airweave.platform.sources.stripe import StripeSource + + class Body(httpx.AsyncByteStream): + reads = 0 + closed = False + + async def __aiter__(self): + for _ in range(3): + self.reads += 1 + yield b"x" * (64 * 1024) + raise AssertionError("Private tail must not be consumed") + + async def aclose(self): + self.closed = True + + body = Body() + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(status, stream=body)) + ) as client: + source = StripeSource( + auth=ManagedAuthProvider( + api_key="fixture", + connected_account_id="fixture", + allowed_hosts=frozenset({"api.stripe.com"}), + ), + logger=MagicMock(), + http_client=AirweaveHttpClient(client, uuid4(), "stripe"), + ) + source.MAX_CAPTURE_RESPONSE_BYTES = 64 * 1024 + with pytest.raises(SourceError) as exc: + await source._read_original("/v1/customers", params={}, headers={}) + assert body.reads == (2 if status == 200 else 0) + assert body.closed + assert "xxxxx" not in str(exc.value) diff --git a/backend/tests/unit/platform/sources/test_wispr_capture.py b/backend/tests/unit/platform/sources/test_wispr_capture.py new file mode 100644 index 0000000000..1ed0d25a50 --- /dev/null +++ b/backend/tests/unit/platform/sources/test_wispr_capture.py @@ -0,0 +1,610 @@ +"""Wispr session binding, query caps, and exact transcript continuations.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from airweave.domains.sources.exceptions import SourceServerError +from airweave.domains.sources.exceptions.classifier import classify_error +from airweave.domains.sources.token_providers.protocol import ManagedToolAuthProvider +from airweave.platform.sources.wispr import WisprSource + + +async def source(): + return await WisprSource.create( + auth=ManagedToolAuthProvider( + api_key="key", connected_account_id="ca_bound", user_id="user" + ), + logger=MagicMock(), + http_client=MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_session_is_bound_and_never_auto_connects(monkeypatch): + connector = await source() + post = AsyncMock( + side_effect=[ + {"session_id": "session"}, + {"data": {"meetings": [], "has_more": False}, "error": None}, + {"data": {"notes": [], "has_more": False}, "error": None}, + ] + ) + monkeypatch.setattr(connector, "_post", post) + await connector.validate() + payload = post.call_args_list[0].args[1] + assert payload["connected_accounts"] == {"wispr_flow_mcp": ["ca_bound"]} + assert payload["manage_connections"] == {"enable": False} + assert "account" not in post.call_args_list[1].args[1] + + +@pytest.mark.asyncio +async def test_preserves_native_ranges_and_continues_exact_offset(monkeypatch): + connector = await source() + execute = AsyncMock( + side_effect=[ + { + "id": "m", + "modified_at": "same", + "content": "notes", + "transcript": ( + "abc\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=3...)\n\nProvider guidance follows." + ), + "native_extra": True, + }, + {"id": "m", "modified_at": "same", "transcript": "def", "content": "notes"}, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + result = await connector._body("meeting", "m") + assert len(result["responses"]) == 2 + assert result["responses"][0]["response"]["native_extra"] is True + assert execute.call_args.args[1]["view_transcript"]["start_char"] == 3 + + +@pytest.mark.asyncio +async def test_changed_meeting_fails_instead_of_mixing_versions(monkeypatch): + connector = await source() + execute = AsyncMock( + side_effect=[ + { + "id": "m", + "modified_at": "old", + "content": "", + "transcript": ( + "(...truncated, 2 chars remaining; " + "continue with view_transcript.start_char=1...)" + ), + }, + {"id": "m", "modified_at": "new", "transcript": "new"}, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + with pytest.raises(ValueError, match="changed"): + await connector._body("meeting", "m") + + +@pytest.mark.asyncio +async def test_explicit_tool_failure_never_becomes_partial_success(monkeypatch): + connector = await source() + monkeypatch.setattr( + connector, + "_post", + AsyncMock(side_effect=[{"session_id": "session"}, {"data": {}, "error": "failed"}]), + ) + with pytest.raises(ValueError, match="tool execution failed; capture is incomplete"): + await connector._body("meeting", "m") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + {"meetings": [], "has_more": False, "truncated": True}, + {"meetings": [], "has_more": True, "next_cursor": None}, + {"meetings": [{"id": "m"}, {"id": "m"}], "has_more": False}, + ], +) +async def test_listing_failure_never_commits_partial_final_page(monkeypatch, response): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + monkeypatch.setattr(connector, "_execute", AsyncMock(return_value=response)) + with pytest.raises(ValueError): + await connector.capture_page( + CompletedScope(record_type="meeting_listing"), ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_listing_preserves_native_row_and_continuation(monkeypatch): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + row = {"id": "m", "title": "Meeting", "unknown_native": True, "start": "2026-01-01T00:00:00Z"} + execute = AsyncMock(return_value={"meetings": [row], "has_more": True, "next_cursor": "next"}) + monkeypatch.setattr(connector, "_execute", execute) + page = await connector.capture_page( + CompletedScope(record_type="meeting_listing"), ScanContinuation(), files=MagicMock() + ) + assert page.records[0].payload == row + assert page.records[0].completeness == "metadata_only" + assert page.records[0].source_created_at is None + assert page.continuation.value["cursor"] == "next" + assert page.final is False + with pytest.raises(ValueError, match="repeated pagination"): + await connector.capture_page( + CompletedScope(record_type="meeting_listing"), page.continuation, files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_partitioned_listing_resumes_both_half_open_windows(monkeypatch): + from airweave.domains.entities.canonical.page_source import CanonicalPageSource + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + assert isinstance(connector, CanonicalPageSource) + rows = [ + {"id": "a", "start": "2026-01-01T00:00:00Z"}, + {"id": "b", "start": "2026-01-03T00:00:00Z"}, + ] + execute = AsyncMock( + side_effect=[ + {"meetings": rows, "has_more": True, "truncated": True}, + {"meetings": rows[:1], "has_more": False}, + {"meetings": rows[1:], "has_more": False}, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + scope = CompletedScope(record_type="meeting_listing") + first = await connector.capture_page(scope, ScanContinuation(), files=MagicMock()) + assert not first.final + # Round-trip persisted progress into a newly constructed source. + resumed = await source() + monkeypatch.setattr(resumed, "_execute", execute) + second = await resumed.capture_page( + scope, + ScanContinuation.model_validate_json(first.continuation.model_dump_json()), + files=MagicMock(), + ) + assert not second.final + third = await resumed.capture_page(scope, second.continuation, files=MagicMock()) + assert third.final + assert execute.call_args_list[1].args[1]["until"] == "2026-01-02T00:00:00+00:00" + assert execute.call_args_list[2].args[1]["since"] == "2026-01-02T00:00:00+00:00" + assert third.records[0].identity.native_id == "b" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "rows", + [ + [{"id": "a"}], + [ + {"id": "a", "start": "2026-01-01T00:00:00Z"}, + {"id": "b", "start": "2026-01-01T00:00:00Z"}, + ], + ], +) +async def test_capped_unsplittable_listing_fails_without_final_page(monkeypatch, rows): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + monkeypatch.setattr( + connector, + "_execute", + AsyncMock( + return_value={ + "meetings": rows, + "has_more": False, + "truncated": True, + } + ), + ) + with pytest.raises(ValueError, match="cannot be partitioned safely"): + await connector.capture_page( + CompletedScope(record_type="meeting_listing"), ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_uncapped_listing_does_not_invent_missing_dates(monkeypatch): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + monkeypatch.setattr( + connector, + "_execute", + AsyncMock( + return_value={ + "meetings": [{"id": "a"}], + "has_more": False, + } + ), + ) + page = await connector.capture_page( + CompletedScope(record_type="meeting_listing"), ScanContinuation(), files=MagicMock() + ) + assert page.final + assert page.records[0].source_created_at is None + assert page.records[0].source_updated_at is None + + +@pytest.mark.asyncio +async def test_short_listing_pages_are_not_limited_to_six_cursors(monkeypatch): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + execute = AsyncMock( + side_effect=[ + {"meetings": [{"id": str(i)}], "has_more": i < 8, "next_cursor": str(i + 1)} + for i in range(9) + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + continuation = ScanContinuation() + for _ in range(9): + page = await connector.capture_page( + CompletedScope(record_type="meeting_listing"), continuation, files=MagicMock() + ) + continuation = page.continuation + assert page.final + + +@pytest.mark.asyncio +async def test_ambiguous_continuation_markers_do_not_choose_a_range(monkeypatch): + connector = await source() + transcript = ( + "Quoted example:\n(...truncated, 10 chars remaining; " + "continue with view_transcript.start_char=10...)\n" + "Actual meeting text follows.\n(...truncated, 20 chars remaining; " + "continue with view_transcript.start_char=40000...)\nProvider guidance follows." + ) + execute = AsyncMock( + return_value={ + "id": "m", + "modified_at": "same", + "content": "notes", + "transcript": transcript, + } + ) + monkeypatch.setattr(connector, "_execute", execute) + with pytest.raises(ValueError, match="ambiguous continuation"): + await connector._body("meeting", "m") + assert execute.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", ["Private meeting: rate limit exceeded", "Too many requests: private detail"] +) +async def test_rate_signal_stops_without_retry_or_invented_status(monkeypatch, error): + connector = await source() + post = AsyncMock(side_effect=[{"session_id": "session"}, {"data": {}, "error": error}]) + monkeypatch.setattr(connector, "_post", post) + with pytest.raises(SourceServerError, match="rate-limit signal") as failure: + await connector._body("meeting", "m") + assert post.await_count == 2 + assert failure.value.source_short_name == "wispr" + assert failure.value.status_code is None + assert "private" not in str(failure.value).lower() + assert "retry delay are unknown" in str(failure.value) + # Do not turn unstructured tool text into credential failure or a timed retry. + assert classify_error(failure.value).category is None + + +@pytest.mark.asyncio +async def test_scratchpad_ranges_preserve_normalized_text_and_native_fields(monkeypatch): + connector = await source() + execute = AsyncMock( + side_effect=[ + { + "id": "n", + "title": "Note", + "modified_at": "2026-09-30T00:00:00Z", + "content": ( + "abc\n(...truncated, 3 chars remaining; " + "continue with view_content.start_char=3...)" + ), + "future_native": {"retained": True}, + }, + {"id": "n", "title": "Note", "modified_at": "2026-09-30T00:00:00Z", "content": "def"}, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + result = await connector._body("scratchpad_note", "n") + assert len(result["responses"]) == 2 + assert result["responses"][0]["response"]["future_native"] == {"retained": True} + assert execute.call_args_list[0].args == ( + "WISPR_FLOW_MCP_GET_SCRATCHPAD_NOTE", + {"note_id": "n", "view_content": {"char_limit": 40000, "start_char": 0}}, + ) + assert execute.call_args.args[1]["view_content"]["start_char"] == 3 + assert all("view_transcript" not in call.args[1] for call in execute.call_args_list) + + +@pytest.mark.asyncio +async def test_scratchpad_partitions_by_modified_time_and_keeps_native_listing(monkeypatch): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + rows = [ + {"id": "n1", "modified_at": "2026-01-01T00:00:00Z", "content_excerpt": "original"}, + {"id": "n2", "modified_at": "2026-01-03T00:00:00Z", "content_excerpt": "next"}, + ] + execute = AsyncMock( + side_effect=[ + {"notes": rows, "has_more": True, "truncated": True}, + {"notes": rows[:1], "has_more": False}, + {"notes": rows[1:], "has_more": False}, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + scope = CompletedScope(record_type="scratchpad_listing") + first = await connector.capture_page(scope, ScanContinuation(), files=MagicMock()) + assert first.records[0].payload == rows[0] and not first.final + assert first.records[0].identity.record_type == "scratchpad_listing" + second = await connector.capture_page(scope, first.continuation, files=MagicMock()) + third = await connector.capture_page(scope, second.continuation, files=MagicMock()) + assert third.final + assert execute.call_args_list[1].args[1]["until"] == "2026-01-02T00:00:00+00:00" + assert execute.call_args_list[2].args[1]["since"] == "2026-01-02T00:00:00+00:00" + assert connector.capture_cycle_configuration.policy("scratchpad_listing") == "discovery_only" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "second", + [ + {"id": "wrong", "modified_at": "same", "content": "def"}, + {"id": "n", "modified_at": "changed", "content": "def"}, + {"id": "n", "modified_at": "same", "content": None}, + ], +) +async def test_scratchpad_mismatched_or_changed_ranges_fail(monkeypatch, second): + connector = await source() + execute = AsyncMock( + side_effect=[ + { + "id": "n", + "modified_at": "same", + "content": ( + "abc\n(...truncated, 3 chars remaining; " + "continue with view_content.start_char=3...)" + ), + }, + second, + ] + ) + monkeypatch.setattr(connector, "_execute", execute) + with pytest.raises(ValueError): + await connector._body("scratchpad_note", "n") + + +@pytest.mark.asyncio +async def test_scratchpad_cap_never_substitutes_meeting_start_for_modified_time(monkeypatch): + from airweave.domains.entities.canonical.requests import CompletedScope + from airweave.domains.entities.canonical.scan_models import ScanContinuation + + connector = await source() + monkeypatch.setattr( + connector, + "_execute", + AsyncMock( + return_value={ + "notes": [ + {"id": "n1", "start": "2026-01-01T00:00:00Z"}, + {"id": "n2", "start": "2026-01-03T00:00:00Z"}, + ], + "has_more": True, + "truncated": True, + } + ), + ) + with pytest.raises(ValueError, match="cannot be partitioned safely"): + await connector.capture_page( + CompletedScope(record_type="scratchpad_listing"), ScanContinuation(), files=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_explicit_no_transcript_preserves_notes_and_native_null(monkeypatch): + connector = await source() + native = {"id": "m", "content": "available notes", "has_transcript": False, "transcript": None} + execute = AsyncMock(return_value=native) + monkeypatch.setattr(connector, "_execute", execute) + result = await connector._body("meeting", "m") + assert result["responses"][0]["response"] == native + assert execute.await_count == 1 + for ambiguous in ( + {**native, "has_transcript": True}, + {k: v for k, v in native.items() if k != "transcript"}, + ): + execute.return_value = ambiguous + with pytest.raises(ValueError, match="not a string"): + await connector._body("meeting", "m") + + +@pytest.mark.asyncio +async def test_transcript_disappearing_after_first_range_stays_incomplete(monkeypatch): + connector = await source() + monkeypatch.setattr( + connector, + "_execute", + AsyncMock( + side_effect=[ + { + "id": "m", + "modified_at": "2026-10-01T00:00:00Z", + "content": "notes", + "has_transcript": True, + "transcript": ( + "abc\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=3...)" + ), + }, + { + "id": "m", + "modified_at": "2026-10-01T00:00:00Z", + "has_transcript": False, + "transcript": None, + }, + ] + ), + ) + with pytest.raises(ValueError, match="not a string"): + await connector._body("meeting", "m") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("version", [{}, {"modified_at": None}, {"modified_at": ""}]) +async def test_multirange_body_requires_version_before_following_continuation(version): + connector = await source() + connector._execute = AsyncMock( + return_value={ + "id": "m", + **version, + "content": "notes", + "transcript": "abc\n(...truncated, 3 chars remaining; " + "continue with view_transcript.start_char=3...)", + } + ) + with pytest.raises(ValueError, match="no usable version"): + await connector._body("meeting", "m") + connector._execute.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [200, 400, 401, 429, 500]) +async def test_streamed_response_limit_closes_success_and_error(monkeypatch, status): + import httpx + + from airweave.domains.storage import FileSkippedException + from airweave.platform.sources import wispr + + class Stream(httpx.AsyncByteStream): + closed = False + + def __init__(self): + self.iterator = self.chunks() + + async def chunks(self): + yield b"x" * 16 + yield b"never needed" + + def __aiter__(self): + return self.iterator + + async def aclose(self): + await self.iterator.aclose() + self.closed = True + + stream = Stream() + monkeypatch.setattr(wispr, "MAX_RESPONSE_BYTES", 8) + monkeypatch.setattr(wispr, "MAX_ERROR_BYTES", 8) + connector = await source() + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(status, stream=stream)) + ) as client: + connector._http_client = client + with pytest.raises(Exception) as caught: + await connector._post("session", {}) + assert not isinstance(caught.value, FileSkippedException) + assert stream.closed + expected = { + 200: "SourceError", + 400: "ComposioProxyError", + 401: "AuthProviderAuthError", + 429: "AuthProviderRateLimitError", + 500: "AuthProviderServerError", + } + assert type(caught.value).__name__ == expected[status] + + +@pytest.mark.asyncio +async def test_nonidentity_encoding_is_rejected_and_valid_unicode_preserved(): + import json + + import httpx + + from airweave.domains.sources.exceptions import SourceError + + connector = await source() + value = {"data": {"text": "你好 café 🎵"}, "error": None} + + def respond(request): + assert request.headers["accept-encoding"] == "identity" + return httpx.Response(200, content=json.dumps(value, ensure_ascii=False).encode()) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + connector._http_client = client + assert await connector._post("session", {}) == value + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response(200, headers={"content-encoding": "br"}, content=b"") + ) + ) as client: + connector._http_client = client + with pytest.raises(SourceError, match="identity encoding"): + await connector._post("session", {}) + + +@pytest.mark.asyncio +async def test_aggregate_archive_limit_counts_native_metadata(monkeypatch): + from airweave.domains.sources.exceptions import SourceError + from airweave.platform.sources import wispr + + connector = await source() + first = { + "id": "m", + "modified_at": "same", + "content": "你好", + "native_metadata": "x" * 250, + "transcript": ( + "(...truncated, 3 chars remaining; continue with view_transcript.start_char=3...)" + ), + } + second = { + "id": "m", + "modified_at": "same", + "transcript": "🎵 café", + "native_metadata": "y" * 250, + } + execute = AsyncMock(side_effect=[first, second]) + monkeypatch.setattr(connector, "_execute", execute) + monkeypatch.setattr(wispr, "MAX_BODY_BYTES", 700) + with pytest.raises(SourceError, match="body exceeds byte limit"): + await connector._body("meeting", "m") + assert execute.await_count == 2 + execute.side_effect = [first, second] + monkeypatch.setattr(wispr, "MAX_BODY_BYTES", 2000) + captured = await connector._body("meeting", "m") + assert [part["response"] for part in captured["responses"]] == [first, second] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status, expected", [(401, "AuthProviderAuthError"), (429, "AuthProviderRateLimitError")] +) +async def test_encoded_error_preserves_header_status_failure(status, expected): + import httpx + + connector = await source() + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response(status, headers={"content-encoding": "br"}, content=b"") + ) + ) as client: + connector._http_client = client + with pytest.raises(Exception) as caught: + await connector._post("session", {}) + assert type(caught.value).__name__ == expected diff --git a/backend/tests/unit/search/test_date_filter_translation.py b/backend/tests/unit/search/test_date_filter_translation.py new file mode 100644 index 0000000000..bd84db6d02 --- /dev/null +++ b/backend/tests/unit/search/test_date_filter_translation.py @@ -0,0 +1,39 @@ +"""Date bounds use the same seconds unit as Vespa documents, without truncation.""" + +from unittest.mock import Mock + +import pytest + +from airweave.domains.search.adapters.vector_db.filter_translator import FilterTranslator +from airweave.domains.search.types.filters import FilterCondition, FilterGroup + + +@pytest.mark.parametrize("field", ["created_at", "updated_at"]) +@pytest.mark.parametrize( + "instant", ["2024-01-01T00:00:00Z", "2023-12-31T16:00:00-08:00", "2024-01-01T00:00:00"] +) +def test_integral_boundary_uses_utc_seconds(field, instant): + translator = FilterTranslator(Mock()) + assert translator._parse_datetime_to_epoch(instant, field) == 1704067200 + + +def test_fractional_half_open_bounds_survive_typed_filter_translation(): + translator = FilterTranslator(Mock()) + result = translator.translate( + [ + FilterGroup( + conditions=[ + FilterCondition( + field="created_at", + operator="greater_than_or_equal", + value="2024-01-01T00:00:00.5Z", + ), + FilterCondition( + field="created_at", operator="less_than", value="2024-01-01T00:00:01.5Z" + ), + ] + ) + ] + ) + assert "created_at >= 1704067200.5" in result + assert "created_at < 1704067201.5" in result diff --git a/backend/tests/unit/search/test_owned_evaluation.py b/backend/tests/unit/search/test_owned_evaluation.py new file mode 100644 index 0000000000..82e4673e49 --- /dev/null +++ b/backend/tests/unit/search/test_owned_evaluation.py @@ -0,0 +1,196 @@ +"""Synthetic delivery boundaries, independent of corpus indexing and credentials.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from evaluation.owned_retrieval import ConversationIdentity, CorpusRecord, delivered_result + +from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, +) +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.search.owned_models import ( + OwnedSearchGroup, + OwnedSearchHit, + OwnedSearchMatch, + OwnedSearchResponse, +) + + +def match(source_id="account-one", sync_id=None, native_id="message", provider="almanac"): + """Build a match plus its separately retained census identity.""" + hit = OwnedSearchHit( + record_id=uuid4(), + revision=1, + sync_id=sync_id or uuid4(), + source_connection_id=uuid4(), + provider=provider, + identity=RecordIdentity(record_type="message", native_id=native_id, container_id="session"), + title="Synthetic", + excerpts=("Synthetic passage",), + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + completeness="complete", + ) + record = CorpusRecord( + record_id=hit.record_id, + sync_id=hit.sync_id, + revision=hit.revision, + source_id=source_id, + provider=provider, + identity=hit.identity, + ) + return hit, record + + +def response(*hits, incomplete=False): + """A delivered bounded response, with no hidden members in items.""" + return OwnedSearchResponse( + items=hits, + sources=(), + candidate_window_full=False, + engine_partial=False, + excluded_candidates=0, + postfilter_excluded=0, + retrieval_incomplete=incomplete, + ) + + +def test_cards_and_displayed_originals_are_different_ranked_units(): + first, first_record = match() + second, second_record = match(sync_id=first.sync_id, native_id="second") + third, third_record = match(native_id="third") + first.group = OwnedSearchGroup( + kind="session", + native_id="session", + matched_records=17, + additional_matches=( + OwnedSearchMatch.model_validate(second.model_dump(exclude={"group"}, round_trip=True)), + ), + ) + membership = ConversationIdentity(kind="session", native_id="session") + first_record = first_record.model_copy(update={"conversation": membership}) + second_record = second_record.model_copy(update={"conversation": membership}) + records = (first_record, second_record, third_record) + delivered = response(first, third, incomplete=True) + cards = delivered_result("q", delivered, records, unit="card") + originals = delivered_result("q", delivered, records, unit="displayed_original") + assert cards.record_ids == ( + first_record.card_id, + third_record.evaluation_id, + ) + assert originals.record_ids == tuple(record.evaluation_id for record in records) + assert cards.status == originals.status == "partial" + assert len(originals.record_ids) == 3 # matched_records is not a delivered member list. + + +def test_card_identity_is_source_scoped_and_stable_across_destination_rebuilds(): + first, first_record = match() + other, other_record = match(source_id="account-two") + rebuilt, rebuilt_record = match() + for hit in (first, other, rebuilt): + hit.group = OwnedSearchGroup(kind="session", native_id="session", matched_records=1) + membership = ConversationIdentity(kind="session", native_id="session") + first_record = first_record.model_copy(update={"conversation": membership}) + other_record = other_record.model_copy(update={"conversation": membership}) + rebuilt_record = rebuilt_record.model_copy(update={"conversation": membership}) + result = delivered_result( + "q", response(first, other), (first_record, other_record), unit="card" + ) + rerun = delivered_result("q", response(rebuilt), (rebuilt_record,), unit="card") + assert len(set(result.record_ids)) == 2 + assert result.record_ids[0] == rerun.record_ids[0] + assert first_record.evaluation_id == rebuilt_record.evaluation_id + assert first_record.evaluation_id != other_record.evaluation_id + + +def test_stale_or_wrong_source_matches_cannot_be_scored_as_frozen_records(): + first, record = match() + with pytest.raises(ValueError, match="frozen source identity"): + delivered_result( + "q", response(first.model_copy(update={"revision": 2})), (record,), unit="card" + ) + with pytest.raises(ValueError, match="Duplicate destination"): + delivered_result("q", response(first), (record, record), unit="card") + second, second_record = match(source_id="account-two") + first.group = OwnedSearchGroup( + kind="session", + native_id="session", + matched_records=2, + additional_matches=(OwnedSearchMatch.model_validate(second.model_dump(exclude={"group"})),), + ) + record = record.model_copy( + update={"conversation": ConversationIdentity(kind="session", native_id="session")} + ) + with pytest.raises(ValueError, match="different source identity"): + delivered_result("q", response(first), (record, second_record), unit="card") + + +@pytest.mark.parametrize("violation", ("wrong_card", "missing_attestation", "foreign_member")) +def test_group_membership_must_match_frozen_census(violation): + first, first_record = match() + second, second_record = match(sync_id=first.sync_id, native_id="second") + membership = ConversationIdentity(kind="session", native_id="session") + first_record = first_record.model_copy(update={"conversation": membership}) + second_record = second_record.model_copy(update={"conversation": membership}) + first.group = OwnedSearchGroup( + kind="session", + native_id="session", + matched_records=2, + additional_matches=(OwnedSearchMatch.model_validate(second.model_dump(exclude={"group"})),), + ) + if violation == "wrong_card": + first.group = first.group.model_copy(update={"native_id": "different-session"}) + elif violation == "missing_attestation": + first_record = first_record.model_copy(update={"conversation": None}) + else: + second_record = second_record.model_copy( + update={ + "conversation": ConversationIdentity(kind="session", native_id="different-session") + } + ) + for unit in ("card", "displayed_original"): + with pytest.raises(ValueError, match="frozen conversation membership"): + delivered_result("q", response(first), (first_record, second_record), unit=unit) + + +@pytest.mark.parametrize("location", ("representative", "additional")) +@pytest.mark.parametrize("violation", (None, "wrong_status", "unknown_status", "unknown_field")) +def test_actual_search_wire_extraction_roundtrip_stays_strict(location, violation): + """The normal API dump includes computed status, including additional matches.""" + coverage = ExtractionCoverage( + parts=(ExtractionOutcome(part_index=0, key="body", kind="body", outcome="indexed"),) + ) + first, _ = match() + second, _ = match(sync_id=first.sync_id, native_id="second") + first.extraction = second.extraction = coverage + first.group = OwnedSearchGroup( + kind="session", + native_id="session", + matched_records=2, + additional_matches=(OwnedSearchMatch.model_validate(second.model_dump(exclude={"group"})),), + ) + wire = response(first).model_dump(mode="json") + extraction = ( + wire["items"][0]["extraction"] + if location == "representative" + else wire["items"][0]["group"]["additional_matches"][0]["extraction"] + ) + assert extraction["status"] == "complete" + if violation == "wrong_status": + extraction["status"] = "partial" + elif violation == "unknown_status": + extraction["status"] = "invented" + elif violation == "unknown_field": + extraction["future_field"] = True + if violation is None: + parsed = OwnedSearchResponse.model_validate(wire) + assert parsed.model_dump(mode="json") == wire + assert ExtractionCoverage.model_validate(coverage.persisted()).status == "complete" + assert "status" not in coverage.persisted() + else: + with pytest.raises(ValueError): + OwnedSearchResponse.model_validate(wire) diff --git a/backend/tests/unit/search/test_owned_replay.py b/backend/tests/unit/search/test_owned_replay.py new file mode 100644 index 0000000000..6b157455c8 --- /dev/null +++ b/backend/tests/unit/search/test_owned_replay.py @@ -0,0 +1,522 @@ +"""HTTP replay boundaries; synthetic fixtures do not qualify a real corpus.""" + +import asyncio +import json +import os +import subprocess +import sys +from pathlib import Path +from uuid import UUID + +import httpx +import pytest +from evaluation.native_import_cli import Settings +from evaluation.owned_retrieval import ConversationIdentity +from evaluation.replay import ( + CorpusMismatch, + FrozenCorpus, + FrozenRecord, + ReplayConfiguration, + replay, +) +from evaluation.replay_cli import ReplayArguments, execute, main +from evaluation.retrieval import Dataset, Judgment, Query + +from airweave.domains.entities.canonical.extraction_models import ( + ExtractionCoverage, + ExtractionOutcome, +) +from airweave.domains.entities.canonical.models import RecordPage, SourceRecord +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.search.owned_models import ( + OwnedSearchGroup, + OwnedSearchHit, + OwnedSearchMatch, + OwnedSearchResponse, +) + + +def fixture(): + """Two delivered members with frozen capture, group and extraction facts.""" + sync_id = UUID(int=100) + parent = RecordIdentity(record_type="session", native_id="session") + extraction = ExtractionCoverage( + parts=(ExtractionOutcome(part_index=0, key="body", kind="body", outcome="indexed"),) + ) + records = tuple( + FrozenRecord( + record_id=UUID(int=i), + sync_id=sync_id, + revision=3, + source_id="synthetic-source", + provider="almanac", + identity=RecordIdentity( + record_type="message", native_id=str(i), container_id="session" + ), + conversation=ConversationIdentity(kind="session", native_id="session"), + capture_hash="a" * 64, + indexed_pipeline_version=2, + extraction=extraction, + parent=parent, + ) + for i in (1, 2) + ) + corpus = FrozenCorpus( + corpus_id="synthetic-corpus", + organization_id=UUID(int=200), + sync_ids=(sync_id,), + records=records, + ) + dataset = Dataset( + corpus_id=corpus.corpus_id, + version="v1", + queries=tuple( + Query( + id=f"q{i}", + text=f"Synthetic query {i}", + tags=("unit:card",), + expectation="relevant_records", + expectation_assessor="synthetic", + judgments=( + Judgment(record_id=records[0].card_id, relevance=3, assessor="synthetic"), + ), + ) + for i in range(4) + ), + ) + live = tuple( + SourceRecord( + id=r.record_id, + sync_id=r.sync_id, + identity=r.identity, + parent=r.parent, + revision=r.revision, + payload={"synthetic": True}, + payload_schema_version=1, + capture_hash=r.capture_hash, + content_hash=None, + completeness="complete", + observed_at="2026-10-01T00:00:00Z", + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=(), + indexed_revision=r.revision, + indexed_pipeline_version=r.indexed_pipeline_version, + ) + for r in records + ) + hits = tuple( + OwnedSearchHit( + record_id=r.record_id, + sync_id=r.sync_id, + revision=r.revision, + source_connection_id=UUID(int=300), + provider=r.provider, + identity=r.identity, + extraction=r.extraction, + title="Synthetic", + excerpts=("Synthetic passage",), + observed_at="2026-10-01T00:00:00Z", + source_created_at=None, + source_updated_at=None, + completeness="complete", + ) + for r in records + ) + hits[0].group = OwnedSearchGroup( + kind="session", + native_id="session", + matched_records=2, + additional_matches=( + OwnedSearchMatch.model_validate(hits[1].model_dump(exclude={"group"})), + ), + ) + wire = OwnedSearchResponse( + items=(hits[0],), + sources=(), + candidate_window_full=True, + engine_partial=False, + excluded_candidates=0, + postfilter_excluded=0, + retrieval_incomplete=True, + ).model_dump(mode="json") + return corpus, dataset, live, wire + + +def run_with_handler(tmp_path, handler): + """Use the same asynchronous HTTP boundary as the real runner.""" + corpus, dataset, _, _ = fixture() + + async def execute(): + async with httpx.AsyncClient( + base_url="http://localhost/api/v1/", + transport=httpx.MockTransport(handler), + follow_redirects=False, + ) as client: + return await replay( + client, + dataset, + corpus, + ReplayConfiguration(system="Synthetic MiniLM", mode="hybrid"), + tmp_path / "run", + ) + + return asyncio.run(execute()) + + +def page(records, *, next_cursor=None): + """Actual public wire serialization, including the live traversal contract.""" + return RecordPage( + records=records, next_cursor=next_cursor, has_more=next_cursor is not None + ).model_dump(mode="json") + + +def test_wire_replay_retains_failed_outcomes_raw_bodies_and_private_permissions(tmp_path): + _, dataset, live, wire = fixture() + calls = [] + + def handler(request): + calls.append(request) + if request.method == "GET": + assert request.url.params["state"] == "active" + assert request.url.params["limit"] == "500" + return httpx.Response( + 200, + json=page(live[:1], next_cursor="second") + if "cursor" not in request.url.params + else page(live[1:]), + ) + query = json.loads(request.content)["query"] + assert json.loads(request.content)["mode"] == "hybrid" + if query.endswith("0"): + return httpx.Response(200, json=wire) + if query.endswith("1"): + return httpx.Response(429, content=b"private provider failure") + if query.endswith("2"): + raise httpx.ReadTimeout("private timeout", request=request) + return httpx.Response(200, content=b"private malformed body") + + run = run_with_handler(tmp_path, handler) + assert [r.status for r in run.results] == ["partial", "error", "timeout", "error"] + assert run.dataset_sha256 == dataset.fingerprint() + assert len(run.results[0].record_ids) == 1 + assert len(run.timings) == 4 + assert [request.method for request in calls] == [ + "GET", + "GET", + "POST", + "POST", + "POST", + "POST", + "GET", + "GET", + ] + output = tmp_path / "run" + assert (output / "request-00001-response.json").read_bytes() == b"private provider failure" + assert not (output / "request-00002-response.json").exists() + evidence = json.loads((output / "evidence.json").read_bytes()) + assert evidence["outcome"] == "verified" + assert evidence["observations"][0]["ranking"]["fallback_reason"] == "unconfigured" + assert evidence["census_before"] and evidence["census_after"] + assert output.stat().st_mode & 0o777 == 0o700 + assert all(p.stat().st_mode & 0o777 == 0o600 for p in output.rglob("*") if p.is_file()) + + +@pytest.mark.parametrize( + "violation", + ("revision", "capture", "pipeline", "missing", "extra", "unavailable", "cursor_loop"), +) +def test_before_census_rejects_changed_or_incomplete_qualified_set(tmp_path, violation): + _, _, live, _ = fixture() + + def handler(request): + assert request.method == "GET" # A rejected census must not issue search. + records = live + if violation == "revision": + records = (live[0].model_copy(update={"revision": 4}), live[1]) + elif violation == "capture": + records = (live[0].model_copy(update={"capture_hash": "b" * 64}), live[1]) + elif violation == "pipeline": + records = (live[0].model_copy(update={"indexed_pipeline_version": 3}), live[1]) + elif violation == "missing": + records = live[:1] + elif violation == "extra": + records = (*live, live[1].model_copy(update={"id": UUID(int=3)})) + elif violation == "unavailable": + records = (live[0].model_copy(update={"content_access": "unavailable"}), live[1]) + return httpx.Response( + 200, json=page(records, next_cursor="loop" if violation == "cursor_loop" else None) + ) + + with pytest.raises(CorpusMismatch): + run_with_handler(tmp_path, handler) + assert not (tmp_path / "run/run.json").exists() + assert json.loads((tmp_path / "run/evidence.json").read_bytes())["outcome"] == "invalid" + + +def test_after_census_rejects_revision_change_and_retains_valid_raw_responses(tmp_path): + _, _, live, wire = fixture() + before = True + + def handler(request): + nonlocal before + if request.method == "POST": + before = False + return httpx.Response(200, json=wire) + records = live if before else (live[0].model_copy(update={"indexed_revision": 4}), live[1]) + return httpx.Response(200, json=page(records)) + + with pytest.raises(CorpusMismatch): + run_with_handler(tmp_path, handler) + output = tmp_path / "run" + assert len(list(output.glob("*-response.json"))) == 4 + assert not (output / "run.json").exists() + assert json.loads((output / "evidence.json").read_bytes())["census_before"] + + +@pytest.mark.parametrize("violation", ("extraction", "revision", "foreign_group")) +def test_delivered_proof_mismatch_aborts_comparison(tmp_path, violation): + _, _, live, wire = fixture() + + def handler(request): + if request.method == "GET": + return httpx.Response(200, json=page(live)) + if violation == "extraction": + wire["items"][0]["group"]["additional_matches"][0]["extraction"] = None + elif violation == "revision": + wire["items"][0]["revision"] = 4 + else: + wire["items"][0]["group"]["native_id"] = "foreign" + return httpx.Response(200, json=wire) + + with pytest.raises(CorpusMismatch): + run_with_handler(tmp_path, handler) + assert (tmp_path / "run/request-00000-response.json").exists() + outcome = json.loads((tmp_path / "run/request-00000-outcome.json").read_bytes()) + assert outcome["status"] == "error" + assert not (tmp_path / "run/run.json").exists() + + +def test_cli_auth_scope_and_redirect_rules_are_applied_to_actual_http(tmp_path, monkeypatch): + corpus, dataset, live, _ = fixture() + dataset_file, census_file = tmp_path / "dataset.json", tmp_path / "corpus.json" + dataset_file.write_text(dataset.model_dump_json()) + census_file.write_text(corpus.model_dump_json()) + constructor = httpx.AsyncClient + calls = [] + + def handler(request): + calls.append(request) + assert request.headers["X-API-Key"] == "synthetic-key" + assert request.headers["X-Organization-ID"] == str(corpus.organization_id) + assert request.url.path.startswith("/api/v1/sync/") + if request.method == "GET": + return httpx.Response(200, json=page(live)) + assert json.loads(request.content)["sync_ids"] == [str(corpus.sync_ids[0])] + return httpx.Response(302, headers={"Location": "https://foreign.invalid/private"}) + + def client_factory(**kwargs): + assert kwargs["follow_redirects"] is False + assert kwargs["trust_env"] is False + return constructor(**kwargs, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr("evaluation.replay_cli.httpx.AsyncClient", client_factory) + result = asyncio.run( + execute( + ReplayArguments( + url="http://localhost/api/v1/", + dataset=dataset_file, + census=census_file, + output=tmp_path / "run", + system="synthetic", + mode="keyword", + ), + Settings(api_key="synthetic-key"), + ) + ) + assert "4 failed" in result + assert len(calls) == 6 # Two census pages and four requests, no redirect destinations. + run = json.loads((tmp_path / "run/run.json").read_bytes()) + assert all(r["status"] == "error" and not r["record_ids"] for r in run["results"]) + + +@pytest.mark.parametrize("violation", ("corpus", "unit", "unknown_label")) +def test_wrong_dataset_binding_rejects_before_network_or_output(tmp_path, violation): + corpus, dataset, _, _ = fixture() + wire = dataset.model_dump(mode="json") + if violation == "corpus": + wire["corpus_id"] = "other" + elif violation == "unit": + wire["queries"][0]["tags"] = ["unit:displayed_original"] + else: + wire["queries"][0]["judgments"][0]["record_id"] = "unknown" + dataset = Dataset.model_validate(wire) + + def handler(request): + pytest.fail("Invalid frozen binding must not make HTTP requests") + + async def execute_invalid(): + async with httpx.AsyncClient( + base_url="http://localhost/", transport=httpx.MockTransport(handler) + ) as client: + return await replay( + client, + dataset, + corpus, + ReplayConfiguration(system="synthetic", mode="keyword"), + tmp_path / "run", + ) + + with pytest.raises(CorpusMismatch): + asyncio.run(execute_invalid()) + assert not (tmp_path / "run").exists() + + +@pytest.mark.parametrize("unit", ("card", "displayed_original")) +def test_oversized_card_response_receives_no_scoring_credit(tmp_path, unit): + corpus, dataset, live, wire = fixture() + additional = wire["items"][0]["group"]["additional_matches"][0] + wire["items"][0]["group"] = None + wire["items"].append({**additional, "group": None}) + corpus = corpus.model_copy( + update={ + "records": tuple( + r.model_copy(update={"conversation": None, "parent": None}) for r in corpus.records + ) + } + ) + live = tuple(r.model_copy(update={"parent": None}) for r in live) + dataset = dataset.model_copy( + update={ + "queries": tuple( + q.model_copy( + update={ + "tags": (f"unit:{unit}",), + "judgments": ( + Judgment( + record_id=corpus.records[0].evaluation_id, + relevance=3, + assessor="synthetic", + ), + ), + } + ) + for q in dataset.queries + ) + } + ) + + def handler(request): + return httpx.Response(200, json=page(live) if request.method == "GET" else wire) + + async def execute_oversized(): + async with httpx.AsyncClient( + base_url="http://localhost/", transport=httpx.MockTransport(handler) + ) as client: + return await replay( + client, + dataset, + corpus, + ReplayConfiguration(system="synthetic", mode="keyword", limit=1, unit=unit), + tmp_path / "run", + ) + + run = asyncio.run(execute_oversized()) + assert all(r.status == "error" and not r.record_ids for r in run.results) + assert ( + len(json.loads((tmp_path / "run/request-00000-response.json").read_bytes())["items"]) == 2 + ) + + +def test_displayed_original_expansion_can_exceed_card_limit(tmp_path): + corpus, dataset, live, wire = fixture() + dataset = dataset.model_copy( + update={ + "queries": tuple( + q.model_copy( + update={ + "tags": ("unit:displayed_original",), + "judgments": ( + Judgment( + record_id=corpus.records[0].evaluation_id, + relevance=3, + assessor="synthetic", + ), + ), + } + ) + for q in dataset.queries + ) + } + ) + + def handler(request): + return httpx.Response(200, json=page(live) if request.method == "GET" else wire) + + async def execute_grouped(): + async with httpx.AsyncClient( + base_url="http://localhost/", transport=httpx.MockTransport(handler) + ) as client: + return await replay( + client, + dataset, + corpus, + ReplayConfiguration( + system="synthetic", mode="keyword", limit=1, unit="displayed_original" + ), + tmp_path / "run", + ) + + run = asyncio.run(execute_grouped()) + assert all(r.status == "partial" and len(r.record_ids) == 2 for r in run.results) + + +def test_http_dtos_and_help_import_without_server_settings_or_database(tmp_path): + root = Path(__file__).resolve().parents[4] + env = {"PATH": os.environ["PATH"], "PYTHONPATH": f"{root / 'backend'}:{root}"} + code = ( + "from evaluation.replay_cli import main; import sys; " + "from airweave.domains.entities.canonical.models import IndexedRecordRead; " + "assert 'extraction' in IndexedRecordRead.model_json_schema()['properties']; " + "assert 'airweave.core.config' not in sys.modules; " + "assert 'airweave.platform.sources' not in sys.modules; " + "assert 'sqlalchemy' not in sys.modules" + ) + imported = subprocess.run( + [sys.executable, "-c", code], env=env, cwd=tmp_path, capture_output=True, text=True + ) + assert imported.returncode == 0, imported.stderr + helped = subprocess.run( + [sys.executable, "-m", "evaluation.replay_cli", "--help"], + env=env, + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert helped.returncode == 0, helped.stderr + assert "--census" in helped.stdout + + +@pytest.mark.parametrize( + "url", + ( + "http://remote.example/api/v1/", + "https://user:private@host/api/v1/", + "https://host/api/v1/?private", + ), +) +def test_cli_reuses_destination_transport_rules(url): + with pytest.raises(ValueError): + ReplayArguments( + url=url, dataset="dataset", census="census", output="run", system="test", mode="keyword" + ) + + +def test_argument_errors_do_not_echo_private_input(capsys): + with pytest.raises(SystemExit) as failure: + main(["--url", "https://private.invalid/secret"]) + assert failure.value.code == 2 + captured = capsys.readouterr() + assert "secret" not in captured.err diff --git a/backend/tests/unit/test_capture_comparison.py b/backend/tests/unit/test_capture_comparison.py new file mode 100644 index 0000000000..a999a8bb49 --- /dev/null +++ b/backend/tests/unit/test_capture_comparison.py @@ -0,0 +1,69 @@ +"""Disposable trial diagnostics distinguish metadata rotation from changed bytes.""" + +import json +from datetime import datetime, timezone +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + +from airweave.domains.entities.canonical.models import SourceRecord +from airweave.domains.entities.canonical.requests import BlobReference, RecordIdentity + + +def test_locator_changes_and_equal_size_different_bytes_are_not_conflated(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "live")) + from capture_comparison import compare_observations + + def record(content): + digest = sha256(content).hexdigest() + return SourceRecord( + id=uuid4(), + sync_id=uuid4(), + identity=RecordIdentity(record_type="message", native_id="private-message"), + revision=1, + payload={"payload": {"parts": [{"body": {"attachmentId": "private-locator"}}]}}, + payload_schema_version=1, + capture_hash="private-capture-hash", + content_hash=None, + completeness="complete", + observed_at=datetime.now(timezone.utc), + source_created_at=None, + source_updated_at=None, + deleted_at=None, + removal_reason=None, + blobs=( + BlobReference( + key="private-key", + sha256=digest, + size_bytes=len(content), + source_path="/payload/parts/0/body", + ), + ), + indexed_revision=None, + indexed_pipeline_version=None, + ) + + rotated, changed = record(b"same"), record(b"old!") + new_rotated = rotated.model_copy( + update={ + "revision": 2, + "payload": {"payload": {"parts": [{"body": {"attachmentId": "other-locator"}}]}}, + } + ) + new_changed = changed.model_copy(update={"revision": 2, "blobs": record(b"new!").blobs}) + before = {row.id: row for row in (rotated, changed)} + after = {row.id: row for row in (new_rotated, new_changed)} + assert sum(blob.size_bytes for row in before.values() for blob in row.blobs) == sum( + blob.size_bytes for row in after.values() for blob in row.blobs + ) + result = compare_observations(before, after, "gmail") + assert result["changed_revision_records"] == 2 + assert result["changed_payload_records"] == result["locator_only_payload_records"] == 1 + assert result["changed_field_categories"] == {"gmail_attachment_locator": 1} + assert result["equal_blob_manifest_records"] == 1 + assert result["different_blob_manifest_records"] == 1 + assert result["equal_blob_size_different_sha_records"] == 1 + serialized = json.dumps(result) + assert all(str(identity) not in serialized for identity in before) + assert "private" not in serialized and "other-locator" not in serialized + assert all(blob.sha256 not in serialized for row in after.values() for blob in row.blobs) diff --git a/deploy/DEPENDENCIES.md b/deploy/DEPENDENCIES.md new file mode 100644 index 0000000000..f55e5517c5 --- /dev/null +++ b/deploy/DEPENDENCIES.md @@ -0,0 +1,169 @@ +# Private staging dependency plan + +Reviewed against current code on 2026-09-30. No resources were provisioned by this +review. This is a proposed single-replica staging topology, not a capacity proof +or production availability design. `porter.yaml` deploys only API and worker. + +## Decision + +Keep one private dependency stack in the existing Porter cluster, with durable +PostgreSQL, Vespa and Redis volumes and canonical bytes in S3. Reuse the official +Temporal Helm chart and a supported PostgreSQL operator/add-on already available +in the cluster. Do not add a second scheduler, managed search product, GPU, +Docling service or paid OCR just to start the worker. + +Prefer an existing maintained Porter PostgreSQL add-on if it supplies backups, +restore and version pinning. Otherwise use CloudNativePG's official chart and +single-instance Cluster resource. Do not create a bespoke database controller. +Use official service images for Redis, Svix and MiniLM with small private service +manifests. A single Vespa StatefulSet with its application package and PVC is +adequate for this bounded staging trial; a new Vespa operator and multi-node +production topology are unnecessary until capacity or availability requires them. + +## Actual dependency boundary + +| Component | Why it is needed | Initial sizing proposal, not measured | +| --- | --- | --- | +| PostgreSQL | Canonical authority, auth, projection ledger, Temporal state and Svix state. Separate databases/roles; do not share the product database. | 0.5 CPU / 2 GiB; 30 GiB durable volume | +| Temporal server | Existing capture/projection/maintenance workflows. Worker cannot poll without it. | One frontend/history/matching/internal worker each; combined 1 CPU / 2 GiB initial requests | +| Redis | Auth context cache, rate limits, pub/sub; Svix delivery queue. Enable persistence for queued delivery. | 0.1 CPU / 256 MiB; 5 GiB volume | +| Vespa | Derived searchable chunks; canonical SQL remains authoritative. | 2 CPU / 8 GiB; 60 GiB volume | +| MiniLM inference | Selected dense embedder. API and worker both check `/health` during composition. | 0.5 CPU / 1.5 GiB; no GPU | +| API | Existing Porter process. | 0.5 CPU / 1 GiB | +| Application worker | Capture, converters, chunker and sparse model share this process. | 1 CPU / 3–4 GiB; current manifest is only 2 GiB and needs measurement | +| Svix | External webhook delivery. Not a startup network prerequisite, but omission loses those events after retries. | 0.1 CPU / 256 MiB; state in PostgreSQL/Redis | +| S3 | Immutable captured blobs and separate database backups. | Private bucket, workload IAM, bounded retention for backups | + +The proposed requests total about 5.7 CPU and 18–19 GiB before Kubernetes and +operator overhead. They are allocation hypotheses, not minimum vendor promises. +Use bounded capture concurrency and observe peak RSS, CPU, disk and retry lag. +Do not claim the current 3 GiB application manifest covers the dependency stack. + +OCR is optional after the worker startup correction. A scanned PDF without OCR +is captured but its projection fails and stays pending; no partial text is fed. +Text-bearing PDF extraction still works locally. Images need OCR. Generative +model keys, Cohere, frontend, Connect UI and Temporal UI are also unnecessary for +owned indexed retrieval. Do not enable their product endpoints as verified. + +Svix details matter: `SvixAdapter.__init__` constructs a client without a probe; +`InMemoryEventBus.publish` logs subscriber exceptions rather than propagating +failure to capture. This is not a durable outbox. Keep Svix for faithful existing +behavior, or separately design an explicit disabled-webhooks mode. Do not point +the service at a nonexistent Svix and describe delivery as reliable. + +## Versions and bootstrap + +Use PostgreSQL **16.15**, already exercised by integration CI. Pin the actual +operator-supported image digest before deployment. Vespa CI used: + +``` +vespaengine/vespa@sha256:5c30f5c41e7563498c4f925db6a837a3848f04726a3ed26aed4a7c8ab69f18fd +``` + +Deploy the repository's 384-dimensional application schema with that image before +API/worker traffic. The current Compose file's `vespa:8`, `redis:7-alpine`, Svix, +Docling and inference image tags are floating and are not a release lockfile. +Resolve and record their architecture-specific digests before applying manifests. + +Published official Temporal chart **1.7.0** and server **1.32.0** were retrieved +and pinned; the chart renders with the proposed values. This is not yet a live +interoperability test or an already tested upgrade. The repository's +Compose `auto-setup:1.24.2` is old development configuration; do not reuse it as +production bootstrap. For a fresh isolated namespace, prefer current official +server/chart with PostgreSQL default and visibility stores, no Cassandra, +Elasticsearch, Grafana, public ingress or UI. Validate rendered chart values: +current chart layout uses `server.config.persistence.datastores`, which differs +from older copied examples. Freeze `numHistoryShards` at initial creation. + +Bootstrap in this order: + +1. Private networking, storage class, node quota and workload IAM; verify the + actual total monthly cost before allocating nodes. +2. PostgreSQL with separate application, Temporal default, Temporal visibility + and Svix databases/users. Keep migration/schema credentials out of runtime + roles. Prove a backup restoration into a disposable database. +3. Redis persistence, Svix, Temporal schema jobs and namespace. Add the `SyncId` + Keyword search attribute explicitly and verify it exists. Never retain the + Compose init command's `|| true`, which can hide failed setup. +4. Vespa application deployment, inference health, model artifact cache. The + Python sparse model and semantic chunker download public artifacts; prewarm a + writable cache or verify restricted-egress startup. This is independent of OCR. +5. Application migration/bootstrap jobs, then API and worker from the same image. + Existing system maintenance schedules remain owned by existing app startup. +6. Set critical health probes to `postgres,redis,temporal`; separately exercise + Vespa query/feed and worker polling. Private service reachability from Almanac + must be explicitly arranged; cluster-private is not cross-cloud connectivity. + +Published artifacts are now pinned in `versions.lock.json`; review-only values and +private service templates are in `staging/`. Official Temporal and CNPG charts +render locally. Explicit unresolved cluster/storage/IAM/secret inputs prevent a +deployment-ready claim. See `staging/README.md` for bootstrap ownership and +remaining gates; no resources were provisioned. + +## Cost envelope and tradeoff + +The supplied Porter baseline is **$201.22/month**, but its component breakdown +has not been verified here. Do not assume it includes dependency compute, disks, +NAT, an external load balancer or Porter usage-based fees. + +An illustrative additional t3a.2xlarge is $0.3008/hour in AWS's published us-east-1 +Linux table: **$219.58 for 730 hours**. Adding 120 GiB gp3 at $0.08/GiB-month gives +**$430.40 combined with that baseline**, before remaining charges. Reserving $40 +for object storage, backup snapshots, logs and traffic yields **$470.40**. This +is a conditional envelope, not a quote or a $500 guarantee. Root disks, NAT, +Porter fees and autoscaling must fit the remaining allowance or replace this +proposal. Do not add this node if the supplied base already includes adequate +capacity. The account quota currently reported by the parent is 8 vCPU versus +16 required by its selected cluster configuration; no quota increase or node +creation was performed here. + +T3 Unlimited is the default and can add CPU-credit charges. Standard mode avoids +that particular overrun by throttling after credits; the 40% baseline gives only +3.2 sustained vCPU on an 8-vCPU instance. Therefore this option favors modest +staging with bounded backfills, not sustained throughput. Compare a fixed-CPU +node quote using the actual existing cluster capacity before choosing. Keep +node autoscaling bounded and an AWS budget alert, while remembering an alert is +not a spending cap. + +Separate managed PostgreSQL/Redis/Temporal/search would reduce some operations, +but fitting all of them plus the existing base under $500 has not been shown. +A separate VM running Compose is cheaper to understand initially but adds another +host, backup owner and network boundary while duplicating the existing Porter +platform. It is a fallback only if the cluster's fixed charges make the reviewed +budget impossible. Single-replica stateful services accept downtime; neither +option is production HA. + +### Existing Porter configuration readback + +On 2026-09-30, the existing cluster6108 form showed Karpenter cost optimization +enabled, max8 application vCPU, on-demand capacity,50GiB node disks, no instance +family restriction and no per-instance size restriction. No settings were saved. +This does **not** establish a dollar ceiling or the proposed t3a.2xlarge price: +the configured pool can select other instance families and sizes. The Porter +[node-group documentation](https://docs.porter.run/cloud-accounts/node-groups) +also describes fixed system and monitoring groups alongside the autoscaled +application group, and private-node egress through NAT by default. Their actual +sizes, node count and NAT charges have not been verified for this cluster. + +Before retrying provisioning, reconcile the original$201.22 estimate against all +three groups, control plane, disks and networking. Select an explicitly priced +bounded instance set or fixed node shape that fits the full$500 cap. Max CPU8 +alone is insufficient evidence. The quota preflight remains a separate gate. + +## Evidence + +Local implementation: `porter.yaml`, `docker/docker-compose.yml`, +`backend/airweave/core/container/factory.py`, `core/config/settings.py`, +`domains/embedders/config.py`, `domains/converters/registry.py`, +`domains/temporal/worker/__init__.py`, `adapters/event_bus/in_memory.py`, +`adapters/webhooks/svix.py`, `.github/workflows/owned-source-store.yml`. + +Primary external references: + +- [Temporal official Helm chart](https://github.com/temporalio/helm-charts) +- [Current Temporal chart metadata](https://raw.githubusercontent.com/temporalio/helm-charts/main/charts/temporal/Chart.yaml) +- [CloudNativePG bootstrap](https://cloudnative-pg.io/documentation/1.27/bootstrap/) +- [Vespa Kubernetes installation](https://docs.vespa.ai/en/operations/kubernetes/operations/installation.html) +- [Svix official server](https://github.com/svix/svix-webhooks) +- [AWS T3 pricing and credit behavior](https://aws.amazon.com/ec2/instance-types/t3/) +- [AWS EBS pricing](https://aws.amazon.com/ebs/pricing/) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000000..d70d2f673b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,131 @@ +# Owned source store staging + +Status: application manifest prepared; dependencies and a running cluster are +still required. Local manifest validation is not a deployment or capacity test. + +The target is Porter project 19566, cluster 6108, AWS us-east-1. No production +cutover. Keep the full incremental bill below $500/month before credits. The +manifest requests 1.5 CPU and 3 GiB for application processes only; PostgreSQL, +Vespa, Temporal, embedding inference, Redis, Svix, disks and networking are +additional. Confirm the total node allocation and price before provisioning. + +## One release image, two processes + +`porter.yaml` builds the backend once and runs the API and Temporal worker. +The API is cluster-private, with no public load balancer. Initially verify it +through an authenticated operator port-forward; the Almanac bridge needs an +explicit reachable, authenticated network path before it can use this endpoint. +Do not mistake `private: true` for a private load balancer reachable from other +clouds. Do not expose PostgreSQL, Vespa or Temporal publicly. + +The entrypoint executes Uvicorn directly so termination reaches the application. +It never prints database URLs, runs migrations, or creates organizations. Worker +shutdown gets 90 seconds inside a 120-second pod grace period. Interrupted jobs +must recover through Temporal and the existing writer fencing, not an assumption +that shutdown always finishes. Test termination under actual capture before +claiming recovery is verified in this deployment. + +## Prerequisites + +Provision isolated PostgreSQL databases for application and Temporal state; +private Redis, Temporal and Svix; persistent Vespa with the matching application +package; local MiniLM inference; and a private S3 bucket for canonical bytes. +Use the repository's existing dependency configurations as inputs, not its local +Compose file as a production deployment: that file enables local auth and exposes +ports. Dependency image pins, persistent volumes, backups and a restore exercise +remain deployment work. This manifest gates API readiness on PostgreSQL, Redis +and Temporal reporting up. Missing/skipped critical probes do not pass. Verify +index, worker polling and capture readiness separately; API dependency health +does not prove those workflows work. + +When Temporal is critical, failure to initialize its system schedules aborts API +startup so the existing process supervisor can retry. Optional Temporal setups +retain their permissive startup behavior. This avoids an API stuck unready with +no initialized Temporal client after a transient startup outage. + +Create the Porter environment group `almanac-source-store-staging` with only the +owned service's values. Supply database connection settings, Redis endpoint, +Temporal endpoint/namespace, Vespa endpoint, TEXT2VEC_INFERENCE_URL, +STORAGE_AWS_BUCKET, SVIX_URL, SVIX_JWT_SECRET and the application's required +encryption/signing secrets. Inspect `backend/airweave/core/config/settings.py` +for exact current fields. Do not copy the main product database from Doppler. +Use a least-privilege workload IAM role for this bucket and configure the Porter +AWS-role connection once that role exists. Do not add static root AWS keys. + +OCR is optional. The worker starts without MISTRAL_API_KEY or DOCLING_BASE_URL. +Text extraction and source capture continue; a scanned document that needs OCR +retains its captured bytes and a failed, retryable projection instead of entering +search with missing content. Configure an OCR backend to index those documents. +This does not mean every PDF requires OCR: the PDF converter first tries local +text extraction. Image conversion requires an OCR backend. + +The selected local MiniLM service is a startup dependency for both API and worker: +composition checks its health endpoint. FastEmbed and the semantic chunker also +load public model artifacts; provide a writable persistent model cache and verify +startup with the intended network policy. OCR, generative search credentials, +Cohere reranking, frontend and Temporal UI are optional for owned indexed search. + +The manifest uses local embeddings to avoid assuming third-party model credits. +Semantic quality and resource use still need measurement. Do not claim generative +search is operational without separately configured model credentials and a live +search test. + +## Local image build + +`make build` builds the same backend Dockerfile used by CI, without publishing or +starting services. Use `make build IMAGE=almanac-source-store:COMMIT` to choose a +tag. CI separately checks packaged Python and entrypoint syntax; a successful +build does not establish dependency startup or a deployment. + +## Database setup and deployment + +Validate locally from the repository root: + +```sh +make validate-deploy +``` + +Against the explicitly verified isolated database, run the committed image as a +one-off operator job with migration credentials: + +```sh +alembic upgrade head +python -m airweave.db.bootstrap +``` + +See `backend/SERVICE_AUTH.md` for organization/key provisioning. Store each key in +secret management. Runtime database credentials should not have migration +privileges. Migrations are intentionally not automatic predeploy hooks: an image +rollback does not reverse database changes. + +After dependencies, IAM, secrets, cluster quota and the reviewed dev commit are +verified, use the existing Porter credentials without printing them: + +```sh +doppler run -p almanac -c dev -- porter apply --project 19566 --cluster 6108 --dry-run -f porter.yaml +doppler run -p almanac -c dev -- porter apply --project 19566 --cluster 6108 --wait -f porter.yaml +``` + +The Doppler invocation authenticates the operator CLI; it does not authorize +injecting every local variable into the application environment group. Build and +deploy from a clean reviewed dev checkout, not a feature branch with dirty work. +Record the resulting image digest and deployed commit. + +Before calling staging working, prove unauthorized requests fail; verify account +scope; capture actual provider records and bytes; read them from a new process; +query the live index; edit/delete disposable source fixtures where permitted; +interrupt and resume a worker; and restore isolated database/blob backups. Record +provider limitations separately from successful checks. Never infer full source +coverage from one healthy request. + +Manifest syntax reference: https://docs.porter.run/applications/configuration-as-code/reference +Private service semantics: https://docs.porter.run/applications/configuration-as-code/services/web-service + +## Private local worker listeners + +Set `WORKER_BIND_HOST=127.0.0.1` for a host-local worker. This binds both its +health/metrics/drain control server and Temporal SDK metrics exporter to loopback. +The default remains `0.0.0.0` for container networking. Binding limits network +reachability; it does not authenticate these endpoints, including `POST /drain`. +Keep container worker ports private. API listener and API metrics bindings are +configured separately. diff --git a/deploy/staging/README.md b/deploy/staging/README.md new file mode 100644 index 0000000000..981b0f225b --- /dev/null +++ b/deploy/staging/README.md @@ -0,0 +1,144 @@ +# Private staging review templates + +**Prepared, not deployed.** These files deliberately contain `REQUIRED_*` values. +Do not apply this directory. Registry retrieval and Helm rendering were checked on +2026-09-30; no Kubernetes admission, service startup, restore, capacity or cost +claim follows from that check. The $500 monthly cap and quota approval remain open. + +## What is pinned + +`../versions.lock.json` records retrieved chart archives and SHA-256, image indexes +and architecture-specific digests. These manifests choose **linux/amd64**; confirm +actual nodes before use. Multi-platform indexes/arm64 alternatives are evidence, +not a reason to silently change the deployed platform. + +| Component | Published artifact | +| --- | --- | +| Temporal | Official chart 1.7.0, server/admin-tools 1.32.0 | +| PostgreSQL | CNPG chart 0.29.1 / operator 1.30.1; PG 16.15-standard-trixie | +| Redis | 7.4.11-alpine3.21 | +| Svix | Server v1.101.0, not the unrelated SDK version | +| Vespa | Existing CI-tested image index, pinned amd64 child | +| MiniLM | all-MiniLM-L6-v2 inference image, pinned amd64 child | + +Primary evidence: [Temporal chart index](https://temporalio.github.io/helm-charts/index.yaml), +[CNPG chart index](https://cloudnative-pg.github.io/charts/index.yaml), +[CNPG image requirements](https://cloudnative-pg.io/documentation/current/image_requirements/), +[Svix pinned CLI](https://github.com/svix/svix-webhooks/blob/server-v1.101.0/server/svix-server/src/main.rs). +Registry manifests were retrieved from Docker Hub/GHCR by the exact references in +the lockfile (`reference` may be a tag or an index digest). Image bytes were not pulled or executed during preparation. + +## Required facts before deployment + +- Dedicated dependency namespace, actual Kubernetes version/CNPG compatibility, + existing operator/add-on ownership, node architecture and quota. Reuse a maintained + PostgreSQL add-on if suitable; do not install a second CNPG operator by accident. +- Verified persistent storage class, encryption, reclaim policy and volume/AZ + placement; working backup destination/IAM and a restore drill. `postgres.yaml` + has **no backup policy yet** and provisions only the initial source-store DB. +- Provision separate `source_store`, `temporal`, `temporal_visibility`, `svix` + databases and schema/runtime roles. Existing-secret references are names, not + credentials. Grant runtime DML/sequence access and default privileges from the + schema owner; no runtime role owns schemas. Review grants against actual migrations. +- Secrets: `source-store-schema` (CNPG basic-auth username/password matching owner), + Temporal `*-schema` / `*-runtime` (password), `source-redis` (password), Svix + `source-svix-schema` / `source-svix-runtime` (environment described below). + App credentials stay in its separately scoped staging environment group. +- Actual application namespace/pod identity, CNI NetworkPolicy enforcement, DNS and + private Almanac-to-service connectivity. Policies deny incoming traffic then allow + dedicated-namespace peers and explicitly selected application callers. They do + **not** restrict egress or supply cross-cloud connectivity. Operator management, + monitoring and bootstrap callers require reviewed ingress rules before use. +- S3 canonical bucket/workload identity, separate backup bucket, restricted egress + and model cache. No secret values, IAM policies or public ingress are invented here. +- Full cost estimate including nodes, disks, load balancers, NAT/egress and backups; + $500 is the cap, not an estimate already proved by these resource requests. + +`ClusterIP` is not authentication. Redis has a password; Temporal and Vespa rely +on the reviewed private network boundary in this initial topology. Do not expose +those ports publicly. Stronger client isolation/TLS must be chosen if the cluster +is shared with untrusted workloads. The default-deny policy is not a substitute +for verifying the CNI enforces it. + +## Bootstrap ownership + +1. Resolve infrastructure facts above. Create secrets out of band; keep them out + of rendered artifacts/version control. Provision DBs/roles before schema jobs. +2. Render Temporal schema **only** with `temporal-schema.values.yaml` overlay and + `--show-only templates/server-job.yaml`. Never install the entire chart with + that overlay: it would give server pods schema credentials. Use a distinct job + name per release; the chart's runtime no-op schema job otherwise has the same + generated name. Runtime values have `manageSchema: false` and DML credentials. + History shards are deliberately 128 for this fresh staging proposal and become + immutable at initialization. Review before the first bootstrap. +3. Run Svix `svix-server migrate` using schema secret, wait for successful exit. + Runtime explicitly runs `svix-server` without `--run-migrations`; its upstream + default launch script would run migrations on every startup. Both secrets need + appropriate `SVIX_DB_DSN`, `SVIX_REDIS_DSN`, `SVIX_JWT_SECRET`, + `SVIX_QUEUE_TYPE=redis`, `SVIX_CACHE_TYPE=none`. Same JWT secret; distinct DB roles. + Do not copy Compose's allow-all webhook subnet whitelist into staging. +4. Start Temporal; create the dedicated Temporal namespace chosen for the app and + the `SyncId` Keyword search attribute, then verify both. Do not mask errors with + `|| true`. Application settings must match this namespace and private frontend. +5. Deploy the committed `vespa/app` package after replacing `{{EMBEDDING_DIM}}` + with 384, `{{VERSION}}` with the release identifier, and the host in `hosts.xml` + with the stable StatefulSet hostname. Wait for prepare/activate **and convergence**. + `vespa/init-vespa.sh` is a Compose helper whose convergence timeout only warns; + it is not a reviewed staging bootstrap job. The application package artifact + and bootstrap job remain a required next step after hostname/release resolution. +6. Explicit application jobs own `alembic upgrade head`, then + `python -m airweave.db.bootstrap` from the same immutable app image. Create the + scoped service-key organization separately per `backend/SERVICE_AUTH.md`. + API/worker entrypoints do not migrate or seed. Do not put schema credentials in + `porter.yaml` runtime configuration. Existing maintenance schedule ownership stays. +7. Verify Redis persistence, inference `/.well-known/ready` (HTTP 204), Vespa feed/query, Temporal worker + polling, source-store API authorization, scoped capture and restore. Probe and + resource tuning remains necessary for Svix/Vespa; these templates don't claim it. + +## Local rendering performed + +Helm 3.22.0 was downloaded into a temporary directory from the official release, +verified against its published SHA-256 and used without changing the system PATH. +Chart archives were verified against the chart-index checksums in the lockfile. +With extracted chart paths and an isolated `HELM` executable: + +```sh +"$HELM" template source-temporal "$TEMPORAL_CHART" --namespace validation-only \ + -f deploy/staging/temporal.values.yaml > /tmp/temporal-review.yaml +"$HELM" template source-temporal "$TEMPORAL_CHART" --namespace validation-only \ + -f deploy/staging/temporal.values.yaml \ + -f deploy/staging/temporal-schema.values.yaml \ + --show-only templates/server-job.yaml > /tmp/temporal-schema-review.yaml +"$HELM" template source-cnpg "$CNPG_CHART" --namespace validation-only \ + -f deploy/staging/cnpg-operator.values.yaml > /tmp/cnpg-review.yaml +``` + +Results: 13 Temporal objects, 1 schema-only job, 22 CNPG objects; YAML parsed and +all rendered workload images carried digests. Temporal emits four single-replica +server deployments, no UI/admin deployment or public service. Schema overlay +renders two schema init containers using separate schema secrets. Runtime render +contains no schema setup command. Initial rendering rejected obsolete top-level +`cassandra`/`elasticsearch`/`prometheus`/`grafana` keys; they were removed because +chart 1.7 no longer bundles these subcharts. This is why copied older values were +not accepted on faith. + +Static service files were parsed and checked for image digests, private Services +and explicit unresolved namespace/storage inputs. These checks do not validate +CRD admission, deployment ordering, credentials or live vendor interoperability. + +Parent review corrected the Vespa reference from an invalid digest-as-tag form +to repository@digest. Six static workload image references were independently +checked for syntax and exact agreement with the lockfile amd64 digests. This is +still not image pull, startup or Kubernetes admission proof. + +### Readiness correction from actual-model CI + +Run36725698424 reached the real inference container but its old `/health` probe +returned404, so model retrieval assertions did not run. Startup validation, +Compose, staging readiness and the integration probe now use +`/.well-known/ready`, matching the [upstream inference API](https://docs.weaviate.io/weaviate/modules/custom-modules). +The integration test requires204 and still verifies actual384-dimensional vectors +and retrieval afterward. Later CI36744440692 at2bb84cf passed the real-model +retrieval lane and image build. That verifies the disposable CI topology; it +does not establish Kubernetes admission, deployed readiness, capacity or restore +for these staging templates. diff --git a/deploy/staging/cnpg-operator.values.yaml b/deploy/staging/cnpg-operator.values.yaml new file mode 100644 index 0000000000..764aaf4a22 --- /dev/null +++ b/deploy/staging/cnpg-operator.values.yaml @@ -0,0 +1,12 @@ +# REVIEW TEMPLATE: resolve every REQUIRED value before any deployment. +image: + repository: ghcr.io/cloudnative-pg/cloudnative-pg + tag: 1.30.1@sha256:b0f9805bd5f98592e7320b5ea624dd10a7a6ef8e477df5650655c94f0b242497 +nodeSelector: + kubernetes.io/arch: amd64 +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi diff --git a/deploy/staging/network-policy.yaml b/deploy/staging/network-policy.yaml new file mode 100644 index 0000000000..ab392ad571 --- /dev/null +++ b/deploy/staging/network-policy.yaml @@ -0,0 +1,30 @@ +# Requires a dedicated namespace and verified CNI enforcement. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: deny-ingress + namespace: REQUIRED_NAMESPACE +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: [] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-reviewed-dependency-callers + namespace: REQUIRED_NAMESPACE +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: REQUIRED_APPLICATION_NAMESPACE + podSelector: + matchLabels: + REQUIRED_CALLER_LABEL: REQUIRED_CALLER_VALUE diff --git a/deploy/staging/postgres.yaml b/deploy/staging/postgres.yaml new file mode 100644 index 0000000000..dad03ea299 --- /dev/null +++ b/deploy/staging/postgres.yaml @@ -0,0 +1,27 @@ +# REVIEW TEMPLATE: resolve every REQUIRED value before any deployment. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: source-postgres + namespace: REQUIRED_NAMESPACE +spec: + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:16.15-standard-trixie@sha256:1ac54199af47712e3c17753a7b6feb87ce2c7627362bd45a80f91d9fb9075249 + storage: + storageClass: REQUIRED_STORAGE_CLASS + size: 30Gi + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + memory: 3Gi + affinity: + nodeSelector: + kubernetes.io/arch: amd64 + bootstrap: + initdb: + database: source_store + owner: source_store_schema + secret: + name: source-store-schema diff --git a/deploy/staging/services.yaml b/deploy/staging/services.yaml new file mode 100644 index 0000000000..7a5de632a2 --- /dev/null +++ b/deploy/staging/services.yaml @@ -0,0 +1,297 @@ +# REVIEW TEMPLATE: not apply-ready; see README gates. +apiVersion: v1 +kind: Service +metadata: + name: redis-headless + namespace: REQUIRED_NAMESPACE +spec: + clusterIP: None + selector: + app.kubernetes.io/name: redis + ports: + - port: 6379 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: REQUIRED_NAMESPACE +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: redis + template: + metadata: + labels: + app.kubernetes.io/name: redis + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + containers: + - name: redis + image: docker.io/library/redis:7.4.11-alpine3.21@sha256:ca0acbb137c1dc3339c8b147a58fd6f42775d4599327b50e7b116c23de501af2 + ports: + - containerPort: 6379 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 256Mi + command: + - sh + - -ec + args: + - exec redis-server --appendonly yes --appendfsync everysec --requirepass + "$REDIS_PASSWORD" + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: source-redis + key: password + volumeMounts: + - name: data + mountPath: /data + serviceName: redis-headless + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: REQUIRED_STORAGE_CLASS + resources: + requests: + storage: 5Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: REQUIRED_NAMESPACE +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: redis + ports: + - name: service + port: 6379 + targetPort: 6379 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minilm + namespace: REQUIRED_NAMESPACE +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: minilm + template: + metadata: + labels: + app.kubernetes.io/name: minilm + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + containers: + - name: minilm + image: docker.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2@sha256:c9c13f047ee31488e686c4a7b2ea7ccfcdec16ee825447450dc047cf97791590 + ports: + - containerPort: 8080 + resources: + requests: + cpu: 500m + memory: 1536Mi + limits: + memory: 1536Mi + env: + - name: ENABLE_CUDA + value: '0' + readinessProbe: + httpGet: + path: /.well-known/ready + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: minilm + namespace: REQUIRED_NAMESPACE +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: minilm + ports: + - name: service + port: 8080 + targetPort: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: svix + namespace: REQUIRED_NAMESPACE +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: svix + template: + metadata: + labels: + app.kubernetes.io/name: svix + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + containers: + - name: svix + image: docker.io/svix/svix-server:v1.101.0@sha256:378a15491d525efeaf93257b710aca9e65b80fb995345b13d86a77df763e588a + ports: + - containerPort: 8071 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 256Mi + command: + - svix-server + envFrom: + - secretRef: + name: source-svix-runtime +--- +apiVersion: v1 +kind: Service +metadata: + name: svix + namespace: REQUIRED_NAMESPACE +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: svix + ports: + - name: service + port: 8071 + targetPort: 8071 +--- +apiVersion: v1 +kind: Service +metadata: + name: vespa-headless + namespace: REQUIRED_NAMESPACE +spec: + clusterIP: None + selector: + app.kubernetes.io/name: vespa + ports: + - port: 8081 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: vespa + namespace: REQUIRED_NAMESPACE +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: vespa + template: + metadata: + labels: + app.kubernetes.io/name: vespa + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + containers: + - name: vespa + image: docker.io/vespaengine/vespa@sha256:ded3e50c1b74ffddbef7944c13cfdfb699db238a2ae377539caf515708fd4ac5 + ports: + - containerPort: 8081 + - containerPort: 19071 + resources: + requests: + cpu: 2000m + memory: 8Gi + limits: + memory: 8Gi + env: + - name: VESPA_CONFIGSERVERS + value: vespa-0.vespa-headless.REQUIRED_NAMESPACE.svc.cluster.local + volumeMounts: + - name: data + mountPath: /opt/vespa/var + serviceName: vespa-headless + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: REQUIRED_STORAGE_CLASS + resources: + requests: + storage: 60Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: vespa + namespace: REQUIRED_NAMESPACE +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: vespa + ports: + - name: service + port: 8081 + targetPort: 8081 +--- +apiVersion: v1 +kind: Service +metadata: + name: vespa-config + namespace: REQUIRED_NAMESPACE +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: vespa + ports: + - port: 19071 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: svix-schema-REQUIRED_RELEASE + namespace: REQUIRED_NAMESPACE +spec: + backoffLimit: 1 + activeDeadlineSeconds: 600 + template: + metadata: + labels: + app.kubernetes.io/name: svix-schema + spec: + restartPolicy: Never + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + containers: + - name: migrate + image: docker.io/svix/svix-server:v1.101.0@sha256:378a15491d525efeaf93257b710aca9e65b80fb995345b13d86a77df763e588a + command: + - svix-server + - migrate + envFrom: + - secretRef: + name: source-svix-schema diff --git a/deploy/staging/temporal-schema.values.yaml b/deploy/staging/temporal-schema.values.yaml new file mode 100644 index 0000000000..539f00124c --- /dev/null +++ b/deploy/staging/temporal-schema.values.yaml @@ -0,0 +1,15 @@ +# REVIEW TEMPLATE: resolve every REQUIRED value before any deployment. +server: + config: + persistence: + datastores: + default: + sql: + user: temporal_schema + existingSecret: temporal-schema + manageSchema: true + visibility: + sql: + user: temporal_visibility_schema + existingSecret: temporal-visibility-schema + manageSchema: true diff --git a/deploy/staging/temporal.values.yaml b/deploy/staging/temporal.values.yaml new file mode 100644 index 0000000000..3c56944506 --- /dev/null +++ b/deploy/staging/temporal.values.yaml @@ -0,0 +1,57 @@ +# REVIEW TEMPLATE: resolve REQUIRED values before deployment. +server: + replicaCount: 1 + image: + repository: docker.io/temporalio/server + tag: 1.32.0@sha256:ca47d4de249b9cc28137628dba77ae5e75e8b313ebb5c801c64615c1c99cbb09 + nodeSelector: + kubernetes.io/arch: amd64 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 1Gi + config: + numHistoryShards: 128 + namespaces: + create: false + persistence: + defaultStore: default + visibilityStore: visibility + datastores: + default: + sql: + pluginName: postgres12 + databaseName: temporal + connectAddr: REQUIRED_POSTGRES_HOST:5432 + user: temporal_runtime + existingSecret: temporal-runtime + secretKey: password + createDatabase: false + manageSchema: false + maxConns: 10 + maxIdleConns: 5 + visibility: + sql: + pluginName: postgres12 + databaseName: temporal_visibility + connectAddr: REQUIRED_POSTGRES_HOST:5432 + user: temporal_visibility_runtime + existingSecret: temporal-visibility-runtime + secretKey: password + createDatabase: false + manageSchema: false + maxConns: 10 + maxIdleConns: 5 +admintools: + enabled: false + image: + repository: docker.io/temporalio/admin-tools + tag: 1.32.0@sha256:8bb84e24db4336de48031a66dbda25a7477d300736fe825893e467042135da1f +web: + enabled: false +schema: + useHelmHooks: false + backoffLimit: 2 + activeDeadlineSeconds: 600 diff --git a/deploy/versions.lock.json b/deploy/versions.lock.json new file mode 100644 index 0000000000..b62d5448ec --- /dev/null +++ b/deploy/versions.lock.json @@ -0,0 +1,117 @@ +{ + "verified_at": "2026-09-30", + "status": "registry-and-render-verification-only", + "candidate_platform": "linux/amd64", + "images": [ + { + "registry": "docker.io", + "repository": "temporalio/server", + "manifest_digest": "sha256:c3e752127759616bb1615e0f9ba0e21635aeb5fdeb922de4f371c350955f46ae", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "amd64": "sha256:ca47d4de249b9cc28137628dba77ae5e75e8b313ebb5c801c64615c1c99cbb09", + "arm64": "sha256:c75012952e4c29acb36ce24243dcdb47822c0134719ea48a1870248620a336d4" + }, + "reference": "1.32.0" + }, + { + "registry": "docker.io", + "repository": "temporalio/admin-tools", + "manifest_digest": "sha256:a9f84fb9a374b2374fe2e67c8efc0468ff3f1c66c8a0b14597ec86e349e62bca", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "amd64": "sha256:8bb84e24db4336de48031a66dbda25a7477d300736fe825893e467042135da1f", + "arm64": "sha256:2fe54408e988e3b53860f77f06742592f1b50a4256d64f28f02fc73213ed6687" + }, + "reference": "1.32.0" + }, + { + "registry": "docker.io", + "repository": "library/redis", + "manifest_digest": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "amd64": "sha256:ca0acbb137c1dc3339c8b147a58fd6f42775d4599327b50e7b116c23de501af2", + "arm64": "sha256:1f09a89a207d794a8c61d9edfc26e7c58427de10ccef7c5d18d638df79a63b85" + }, + "reference": "7.4.11-alpine3.21" + }, + { + "registry": "docker.io", + "repository": "svix/svix-server", + "manifest_digest": "sha256:7ba594c766c882dea9c868cd38957755b1d5c8bc5020f81cf5521ba5ab5adc11", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "arm64": "sha256:fc0fb52e82e739bab030b782835e0e539f64e6686076cb6b03f74d0224460b12", + "amd64": "sha256:378a15491d525efeaf93257b710aca9e65b80fb995345b13d86a77df763e588a" + }, + "reference": "v1.101.0" + }, + { + "registry": "docker.io", + "repository": "semitechnologies/transformers-inference", + "manifest_digest": "sha256:75359f8b886cf51b1e4d971172da1c22776acca7321043b63a24b8d23b710879", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "arm64": "sha256:695dbbeaf67d30a585db42a1c2ecebd8351a08ea11197022db680002095227a2", + "amd64": "sha256:c9c13f047ee31488e686c4a7b2ea7ccfcdec16ee825447450dc047cf97791590" + }, + "reference": "sentence-transformers-all-MiniLM-L6-v2" + }, + { + "registry": "docker.io", + "repository": "vespaengine/vespa", + "manifest_digest": "sha256:5c30f5c41e7563498c4f925db6a837a3848f04726a3ed26aed4a7c8ab69f18fd", + "media_type": "application/vnd.docker.distribution.manifest.list.v2+json", + "platform_digests": { + "amd64": "sha256:ded3e50c1b74ffddbef7944c13cfdfb699db238a2ae377539caf515708fd4ac5", + "arm64": "sha256:125ea9b64d0b4c31bd84502a6dfc2f3c1e44487b4fbee606959e3c418b1d1964" + }, + "reference": "sha256:5c30f5c41e7563498c4f925db6a837a3848f04726a3ed26aed4a7c8ab69f18fd" + }, + { + "registry": "ghcr.io", + "repository": "cloudnative-pg/postgresql", + "manifest_digest": "sha256:1dd42563007500b94b943f2a1620f724efedfc9eea2d07a50c6db4582c2431fd", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "amd64": "sha256:1ac54199af47712e3c17753a7b6feb87ce2c7627362bd45a80f91d9fb9075249", + "arm64": "sha256:d825f9b9b11ffd1772504d9cc5a358c7ea524f33aefb38a3f095615acbc04845" + }, + "reference": "16.15-standard-trixie" + }, + { + "registry": "ghcr.io", + "repository": "cloudnative-pg/cloudnative-pg", + "manifest_digest": "sha256:923c267ec29636db3bee20f993d0ec4973fa22998e1adad37da79e4d32b5bc07", + "media_type": "application/vnd.oci.image.index.v1+json", + "platform_digests": { + "amd64": "sha256:b0f9805bd5f98592e7320b5ea624dd10a7a6ef8e477df5650655c94f0b242497", + "arm64": "sha256:9a10dd7d7466310d5d543f618478cb905093cfc7a5463b538f449da626660083" + }, + "reference": "1.30.1" + } + ], + "charts": [ + { + "name": "temporal", + "version": "1.7.0", + "app_version": "1.32.0", + "url": "https://github.com/temporalio/helm-charts/releases/download/temporal-1.7.0/temporal-1.7.0.tgz", + "sha256": "f7b443f637b71fcbddd12900e5be9fe4c18cf741495d7b2cb247670681bf57d4" + }, + { + "name": "cloudnative-pg", + "version": "0.29.1", + "app_version": "1.30.1", + "url": "https://github.com/cloudnative-pg/charts/releases/download/cloudnative-pg-v0.29.1/cloudnative-pg-0.29.1.tgz", + "sha256": "b53d3991fe84bcf38767e7702cae78666265427a127a26fff168ab4207d2b1df" + } + ], + "render_tool": { + "version": "3.22.0", + "url": "https://get.helm.sh/helm-v3.22.0-darwin-arm64.tar.gz", + "sha256": "4c9982a6cdeb458b60258df66b55398ca5b19293f6877faffe2909ad6f23dfe0", + "platform": "darwin/arm64" + } +} diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 8fb7324ce6..f84ff257da 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -40,7 +40,7 @@ services: ENABLE_CUDA: 0 WORKERS_PER_NODE: 1 healthcheck: - test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8080/health" ] + test: [ "CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/.well-known/ready" ] interval: 30s timeout: 10s retries: 3 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a50d553ddc..9a0a75f172 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -157,7 +157,7 @@ services: ENABLE_CUDA: 0 WORKERS_PER_NODE: 1 healthcheck: - test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8080/health" ] + test: [ "CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/.well-known/ready" ] interval: 30s timeout: 10s retries: 3 diff --git a/docs/owned-sources/cohere-embeddings.md b/docs/owned-sources/cohere-embeddings.md new file mode 100644 index 0000000000..c67cfc2753 --- /dev/null +++ b/docs/owned-sources/cohere-embeddings.md @@ -0,0 +1,32 @@ +# Cohere embedding adapter + +2026-10-01. Implemented; SDK/HTTP transport tests only. No live inference, +quality benchmark, deployment change or existing-index migration claimed. + +The existing dense embedder port now accepts an explicit batch purpose: +`document` (default for indexing) or `query`. Both current and legacy query +execution paths pass `query`; symmetric existing providers retain their behavior. +Cohere maps purpose to search_document/search_query. It uses the installed SDK, +requests float vectors, rejects truncation and validates count, dimensions and +finite values. Requests are sequential batches of at most96 texts with a60-second +transport timeout and SDK retries disabled; caller orchestration owns retries. +Provider error text is not copied into public exceptions. + +Registry choices: cohere_embed_v4, cohere_embed_v5_pro, cohere_embed_v5_fast. +Configure COHERE_API_KEY, DENSE_EMBEDDER and EMBEDDING_DIMENSIONS explicitly. +The adapter validates the discrete dimensions supported by each model. These are +text registrations, not image-processing or cross-model Pro/Fast query support. +Use the existing deployment metadata mismatch guard; do not overwrite a MiniLM +index with Cohere vectors. Create an isolated model-qualified evaluation index +before changing runtime configuration. Retain the old runtime for comparison. + +Official references: +- https://cohere.com/blog/embed-5 +- https://docs.cohere.com/v2/docs/semantic-search-embed + +The standalone import API remains to be designed and implemented: current source +provisioning requires a verified managed-provider identity. Wiki/session imports +must instead bind a trusted publisher and scope without pretending to be Composio +accounts. Retained revisions, fenced writes, indexing work and read authorization +should reuse existing store machinery. A new public write endpoint must not expose +writer fences or let callers nominate arbitrary organizations/principals. diff --git a/docs/owned-sources/local-ocr.md b/docs/owned-sources/local-ocr.md new file mode 100644 index 0000000000..1047e4669c --- /dev/null +++ b/docs/owned-sources/local-ocr.md @@ -0,0 +1,56 @@ +# Optional local OCR + +The shared OCR provider can use PyMuPDF's bundled Tesseract runtime with explicitly +provisioned language models. It uses the existing conversion interface and does +not introduce a service, database, or Python dependency. Original bytes are +unchanged; recognized text is a derived representation, not an exact transcription. + +## Configuration + +The backend image includes checksum-pinned `tessdata_fast` English and Hindi +models and their Apache 2.0 license under `/usr/share/tessdata`. Enable the adapter: + +```sh +LOCAL_OCR_TESSDATA_PATH=/usr/share/tessdata +LOCAL_OCR_LANGUAGES='["eng","hin"]' +``` + +Without the path, local OCR stays disabled. Missing configured models fail startup. +Inference does not download models. Other languages require provisioning their +model files and explicitly adding their language codes. + +Changing OCR configuration does not automatically rebuild published generations. +Retry failed projections explicitly; use the existing source reprojection/version +operation when replacing already-published text. Do not silently mix a changed +extraction configuration into a claim that an old generation was reprocessed. + +The configured chain is local OCR, then Mistral, then Docling. Only unresolved +files reach the next configured provider; successful files are not processed +again. A configured cloud fallback can incur its normal charges. Local OCR uses +CPU and memory, so zero external inference calls does not mean zero hosting cost. + +## Bounds and failure behavior + +Each file runs sequentially in a disposable process, killed and reaped on timeout +or cancellation. Limits: 120 seconds, 200 MiB input, 200 pages, 20 million rendered +pixels per page at 150 DPI, and 8 MiB UTF-8 output. PDF, PNG, JPEG, BMP, WebP and +single-frame TIFF are supported. Encrypted PDFs and multiframe images are refused. +An over-limit or failed file returns no text to the fallback chain; it does not +publish a truncated prefix. These are processing bounds, not a full OS memory +sandbox. Native parser memory use still depends on the file. + +Full-page OCR is intentional: the installed PyMuPDF partial-OCR path can omit +vector-drawn content. This may recognize native text less faithfully than direct +extraction. Original downloads remain the reference for exact reading. + +## Qualification + +`make test-ocr` checks fallback behavior, configuration and subprocess boundaries. +Set `LOCAL_OCR_TEST_TESSDATA` to a model directory to include real extraction tests. +The image CI runs `python -m scripts.smoke_local_ocr --tessdata /usr/share/tessdata`. + +Local evaluation recovered a retained three-page PDF that previously failed +conversion, without changing its original hash. A synthetic English/Hindi image +recovered the English identifier and Hindi characters but dropped a space between +Hindi words. This demonstrates execution and a real quality limitation; it is not +a multilingual accuracy benchmark or proof of complete provider indexing. diff --git a/docs/owned-sources/provisioning.md b/docs/owned-sources/provisioning.md new file mode 100644 index 0000000000..34a13f4e1a --- /dev/null +++ b/docs/owned-sources/provisioning.md @@ -0,0 +1,80 @@ +# Owned account provisioning + +This backend-only API is implemented; it has not been deployed or qualified against live Temporal/provider services. Almanac still needs durable delivery from its account authority before Connect automatically provisions capture. + +`PUT /owned-sources/{account_uuid}` commits a desired account generation and reconciles it. `GET` reads the committed delivery state. Both require an organization API key, whose existing broad source-management authority remains unchanged. Never expose this key or endpoint directly to browsers. The server fixes the namespace to `almanac`. + +An active request contains `generation`, `state: "active"`, and `source` with `provider`, `expected_identity`, `collection`, `auth_provider`, `connected_account_id`, `auth_config_id`, `user_id`, `config`, and five-field `cron`. Composio selectors contain no provider tokens. The configured auth-provider connection must belong to the request organization. Native validation checks the expected identity before admitting capture. Supported providers are Gmail (mailbox), Google Calendar (primary calendar ID), and Google Drive (permission ID). Slack and Wispr identity contracts remain future work. + +A disconnect contains only `generation` and `state: "disconnected"`. Active credential rotation preserves source/sync IDs and retained originals. Disconnect is a tombstone for this account UUID, matching Almanac's account lifecycle; a subsequent new Connect uses a new UUID. Deletion of retained records is not part of disconnect. + +Same generation and identical payload reuse the same source, sync and initial job. Changed payload at the same generation or an older generation returns 409. A higher generation cannot change provider, native identity or collection. Ordinary source update/delete routes reject provisioned sources to prevent bypassing this lifecycle. + +The response includes account/organization IDs, current/observed generations, source/sync IDs, expected identity and `pending`, `ready`, or `disconnected`. `ready` means native identity was verified and scheduling/start was acknowledged; it does **not** mean records were captured or indexed. Read source coverage for that evidence. Do not publish an account binding merely because IDs exist in a pending response. + +## Recovery ownership + +Database source creation and desired-generation changes commit atomically before external execution. Provider verification occurs outside the relationship lock and must pass a generation comparison afterward. Jobs copy the current generation under the sync lock; unverified, paused and stale managed generations cannot enter capture. Canonical write fences prevent already-running older jobs from committing after rotation/disconnect. + +The bounded 20-second execution phase serializes cancellation and scheduler changes under the relationship lock. Timeouts release the lock without acknowledging cleanup; cancellation job IDs and the desired generation remain committed for retry. Deterministic schedules are removed even when their database link is missing, then recreated with current generation arguments. The initial workflow uses its durable job UUID and rejects duplicate workflow reuse. + +After an uncertain response, Almanac must retry the identical PUT until acknowledged, or deliver a newer generation. GET does not retry side effects. This API introduces no second queue or autonomous reconciliation worker: the planned Almanac durable account intent owns delivery/retry. Native verification and infrastructure errors may leave a pending intent, and live operational retry/alert behavior remains to be integrated there. + +## Local verification + +The provisioning tests use migrated PostgreSQL and actual source/sync creation, with synthetic identities and mocked provider/Temporal I/O. They cover lost replies, transaction rollback, rotation cleanup timeout, disconnect racing native verification, organization/API-key boundaries, and legacy mutation rejection. A separate actual TemporalScheduleService test uses fake remote handles to prove orphan schedule replacement and relinking. These tests are not live deployment qualification. + +## Reversible pause + +`state: paused` accepts no source specification, fences existing jobs and removes +schedules using the same durable cleanup as disconnect. It retains the source/sync +IDs and original protected SourceConnection config. A higher-generation active +request revalidates that exact provider/native identity/collection before rotating +credentials and resuming. The stop request does not replace that authority. +Inactive responses return the preserved expected identity when a source exists. +An initially paused account may create its first source later; disconnected is +terminal even if no source was ever created. Pause is for configuration disable; +disconnect is for account removal. + +Pause qualification: seven real PostgreSQL provisioning tests passed (including +active → paused → same-ID active, mismatched native identity rejected, terminal +existing/initial disconnect, and initially paused → first active). Provider native +validation and Temporal calls are simulated; no live account operations occurred. +Log: `/tmp/provisioning-pause-tests.log`. Migration 0010's state constraint was +extended before deployment; deployed schemas would require a new migration. + +## Slack principal + +Slack requests use `provider: slack`, `expected_identity` for the native workspace +ID and `expected_user_identity` for the native Slack user. The latter is required +only for Slack and is distinct from Composio's `user_id`. Both overwrite conflicting +free-form config fields, feed the existing native `auth.test` capture fence, survive +pause acknowledgements, and must match on resume. Response acknowledgements include +both fields; Google responses have null expected_user_identity. The Almanac adapter +maps `slack_composio` to this provider and verifies both acknowledgement identities. +This admits capture; it does not claim complete Slack incremental edit/delete coverage. + +Verification: eight real-SQL provisioning tests passed (native/Temporal calls mocked), +including wrong-user/same-workspace rejection and pause/resume preservation. Almanac +adapter's 18 tests passed, including rejection of a wrong native-user acknowledgement. +Wispr remains outside automatic provisioning pending native-principal qualification: +the saved catalogue exposes GET_ACCOUNT_INFO but its output schema is unspecified. + +### Wispr identity probe (2026-09-30) + +A bounded live read used the existing saved pilot, verified its exact Composio +connected-account ID, ACTIVE state, toolkit and auth-config, then executed only +`WISPR_FLOW_MCP_GET_ACCOUNT_INFO` in an account-pinned session with connection +management disabled. The successful native response contained exactly `name: str` +and `aliases: list[str]`. No values, tokens, names or aliases were logged or stored. +The first session request returned 201 and was incorrectly treated as incomplete +by the disposable diagnostic; a second accepted session completed the single +account-info execution. No inventory read, account grant or provider mutation ran. + +This response supplies display identity, not a stable native principal. It cannot +prove a reconnect preserves the same Wispr account. The automatic native-attested +path remains unsupported for Wispr; the existing explicitly broker-bound pilot +is not upgraded by this probe. A future alternative requires either documented +stable native identity or an explicit product policy for broker-only authority +without silently permitting cross-credential continuity. Discovery/deletion and +raw editor/dictation limits remain unchanged. diff --git a/docs/owned-sources/representations.md b/docs/owned-sources/representations.md new file mode 100644 index 0000000000..74b0c63eda --- /dev/null +++ b/docs/owned-sources/representations.md @@ -0,0 +1,203 @@ +# Retained text implementation plan + +Goal: read complete derived text from retained originals, with the exact revision, +pipeline and publication generation used by search. Original provider records and +bytes remain authoritative. No additional transcript store, source fetch or CLI +vocabulary is introduced. + +The text builder will record complete indexing text and its converter-content +boundary at construction. Indexing and artifact persistence consume that same +result, without rerunning conversion or reconstructing text from snippets. Generated +metadata remains distinguishable; content reads default to the converter output. +Existing PDF converters flatten page boundaries, so source page anchors remain +explicitly unavailable. Character ranges identify the declared derived text only. + +Generation manifests will retain immutable representation descriptors, not bodies. +The existing object store holds UTF-8 bytes. A manifest commits before external +writes; publication uses the existing compare-and-swap after blob and index writes. +Reads use the same publication/ancestor-visibility predicate before and after +bounded, checksum-verified storage access. Retired-generation garbage collection +owns derived blobs, including abandoned attempts and repeated late writes. + +Scope: full PDF/readable text and declared generated record representations. +No transcription, video understanding, visual fidelity or source-page coordinates +are implied. Existing generations without representation metadata stay explicitly +unavailable until reprojected. + +Proof required: converter runs once; text beyond a search excerpt remains readable; +generated/content boundary survives marker-like user content; prepared but +unpublished bytes cannot be read; revision/pipeline/revocation changes deny reads; +corrupt blobs fail closed; retirement reclaims artifacts and retries failures. +Synthetic provider/PDF and migrated SQL tests are separate from live qualification. + +## Implemented internal read contract + +`GET /sync/{sync_id}/records/{record_id}/text-representations?revision=N` +returns `record_id`, `revision`, `status` (`available` or `unavailable`) and +`representations`. Each descriptor contains an opaque `id`, parent `record_id`, +`revision`, `generation`, `pipeline_version`, source-local `part_key`, +`kind` (`native_text`, `extracted_text`, or `generated_text`), `media_type: text/markdown`, +`content_characters` (null for generated-only text), `index_characters`, and +`source_anchors: unavailable`. Storage keys and internal part ordinals are omitted. + +`GET` of that path plus `/{representation_id}` requires `revision` and `generation`. +It accepts `offset` (default 0), `limit` (default 12000, maximum 100000), and +`view=content|index` (default content). Offsets count Unicode code points in that +view. The response contains `representation`, `view`, `offset`, `total_characters`, +`text`, and nullable `next_offset`. Follow next_offset with the same immutable +reference/view. The default excludes generated metadata; generated-only records +require explicit index view. An unavailable/changed generation returns 409, and +withdrawn content returns 404. Neither falls back to a provider or search excerpt. + +These are internal service APIs for Almanac's established read surface, not new +public CLI commands. Existing organization-scoped backend authorization applies. +Almanac must enforce its user/account binding before calling them. + +UTF-8 artifacts use the existing per-file storage size bound; publication fails +instead of silently truncating an oversized conversion. Reads verify byte length, +SHA256 and character count. Migration 0011 adds nullable body-free descriptors; +old generations require explicit reprojection before this representation exists. + +## Verification evidence + +Local synthetic tests exercise an actual three-page PDF converter, migrated +PostgreSQL publication, filesystem blobs and authenticated ASGI read routes. +Chunking/embedding are fixed substitutes: this is a content/authorization proof, +not a search-quality or live-deployment claim. Tests cover unpublished artifacts, +full content versus metadata, corruption, mid-read revision/pipeline changes, +parent withdrawal, failed cleanup and late blob recreation. Strict exact-object +storage deletion propagates failure; cloud version retention remains backend policy. + +The canonical/pipeline/wiring regression run passed 376, skipped 2, with one +30-second child-process timeout; that unchanged lifecycle test passed in 9.18s +on isolated rerun. Final focused retained-text/exclusion/worker checks passed 34. +The worker readiness mock now uses the real `/.well-known/ready` path and tests +both healthy 200 and unavailable 503. No private provider calls or deployment ran. + +## Gmail size metadata and configured extraction coverage + +| Native capability/resource | Product name | Access operation | Stored representation | Sync/change guarantee | Proof | Gap | +| --- | --- | --- | --- | --- | --- | --- | +| Inline MIME body / external body bytes | Email text | Retained record projection | Native JSON unchanged; external blob verified against canonical actual byte size and SHA256 | Projection does not refetch or modify capture | Synthetic declared-size discrepancy succeeds; invalid base64 and corrupt canonical bytes still fail | Live retained inline bodies showed native size one byte smaller than decoded data; no upstream cause established | +| Inline images and attachments | Email extraction parts | Configured converter registry | Available body text plus explicit unsupported part coverage when converter absent | Reprojection follows existing pipeline-version lifecycle | Real SQL partial publication without OCR; configured converter failure still blocks publication | Adding OCR requires pipeline-version change to retry prior unsupported parts; no image understanding claim | + +Google documents MIME `size` as byte count, but it is provider metadata rather +than our storage-integrity checksum. Projection preserves the reported value +and consumes the retained base64 data or verified canonical blob without an +additional provider-size equality requirement. External attachment acquisition +keeps its existing checks; this change does not claim that path was requalified. +[Native MessagePartBody contract](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages.attachments#MessagePartBody). + +Unsupported means no configured converter for that part. It does not turn an +installed converter's failure into success: malformed PDFs, OCR errors, and +other attempted-conversion failures still leave publication pending. Scanned +PDFs with a configured PDF converter but no OCR fallback remain in that category. + +### Drive originals omitted during capture + +A non-folder Drive file explicitly captured as `metadata_only` with no blobs +publishes `unavailable_original` / `original_not_captured` extraction evidence, +zero searchable chunks, and unavailable text. It does not index a title as if it +were the document. A complete record missing its required blob remains an error. +The retained evaluation presentation failure was this omission, not a converter +failure: native size exceeded the initial capture limit and no bytes were stored. + +| Native capability/resource | Product name | Access operation | Stored representation | Sync/change guarantee | Proof | Gap | +| --- | --- | --- | --- | --- | --- | --- | +| File metadata without captured body | Original / extraction availability | Existing record and extraction reads | Native metadata plus explicit unavailable-original coverage | Later captured bytes form a new revision; loss of body retires previous text eligibility | Real SQL metadata-only → retained text → metadata-only lifecycle; complete/missing-blob remains error | Actual recapture depends on provider access and configured limits; no converter success claimed | + +## Slack retained file projection + +Slack messages now enumerate their body and each native `files[]` identity as +separate extraction parts. A retained file blob must reference its exact +`/files/N` location and pass the existing sync ownership, SHA-256 and byte-count +checks. Supported retained files use the existing converter/chunker/embedding +pipeline; missing original bytes are explicit `unavailable_original` parts. The +body remains searchable when a file was deliberately not captured. Unknown or +unsupported conversion outcomes retain the shared strict/unsupported semantics. + +| Native resource | Product name | Access | Stored representation | Change guarantee | Proof | Gap | +| --- | --- | --- | --- | --- | --- | --- | +| Message text | Slack message | Stored read/search | Native JSON | Existing message capture | Offline mapper and SQL publication | Existing source freshness limits | +| Hosted file | Message attachment | Stored original/extraction when retained | Existing immutable blob, `/files/N` | Same message revision and visibility | Synthetic PDF conversion/publication; integrity tests | Source byte acquisition not implemented by this change | +| Uncaptured file | Unavailable attachment | Coverage only | Native metadata | No byte-completeness claim | Explicit missing part tests | No inferred download or extracted content | + +Native file IDs identify extraction parts; byte-identical files with distinct IDs +remain distinct parts. Duplicate file IDs or malformed metadata fail projection +rather than choosing an ambiguous original. Complete records missing declared +file bytes fail integrity checks; explicitly partial originals can publish partial +extraction. No provider URL is fetched during projection. Attachments remain +parts of their parent message: this adds no independent file authorization or +file-only search facet. + +Existing indexed Slack generations require explicit reprojection before this +coverage appears. No global pipeline version was bumped. Slack original acquisition remains opt-in (`capture_files=false` by default). +This projection change does not establish live `files:read` access, successful +provider downloads, whole-workspace completeness, or freshness. + +When present, the Slack file acquisition manifest is read from its verified +`representation_manifest` blob. Its ordered native IDs must match the unchanged +message file list. Retained `files.info` enrichment can supply filename/MIME type +for Slack Connect stubs, but cannot replace the file identity. A captured outcome +requires exactly one native-path blob; an unavailable outcome must have none. +Contradictory evidence fails projection. Legacy records without a manifest retain +the explicit missing-original behavior above. + +The companion capture contract requires an original URL, native size and MIME +type, resolving missing fields through `files.info`. Missing metadata and explicit +file denial remain partial acquisition evidence; missing OAuth scope fails the +configured capture. Downloaded byte-count mismatch fails the page. Capture still +commits whole provider pages: aggregate page download/retry cost has not been +qualified live, even though individual files and metadata counts are bounded. + +### Durable Slack file children + +The child-owned topology is explicit: message payload schema **2** projects only +its body and must contain no inline file blobs. Its unchanged native `files` list +is inventory, not evidence that downloads completed. Schema **1** messages retain +the legacy inline behavior described above; missing blobs never select topology. +Unknown message schemas fail projection. + +A child `file` record must have its exact message parent, the canonical full-parent +container key, and the matching native file ID in its original payload. The one +original blob uses the empty JSON Pointer (`source_path=""`) because that record's +payload is the native file object itself. A singleton acquisition manifest supplies +captured/unavailable evidence and optional native metadata enrichment. Only the +child owns that file's extraction part. Generic ancestry continues to authorize +its reads, downloads and search; a locator never grants independent file access. + +Switching requires recapture under the new source fingerprint, schema-2 parent +revisions and child acquisition. Parent revision validation hides the old inline +publication before the replacement body-only generation and child generations +publish. No blob is copied back to the parent and no global pipeline version is +changed. Deployment and product reader migration remain separate activation gates. +Tests exercise a real retained PDF transitioning from an inline publication to one +body-only parent, one indexed file child and one unavailable child. Provider HTTP, +real embeddings and live Vespa are not part of this synthetic SQL proof. + +### Native Slack message text + +Slack message projections select the captured `/text` string as `native_text`. +The same builder appends that string to generated search context and records its +exact character boundary. Retention and indexing consume that single result; +content reads return the string verbatim, without the generated context. An +explicit empty string is available empty content. A missing field has no native +content view; a present non-string field fails projection. Generated display +names are never substituted for native content. + +This is the `/text` representation, not a rendering of Slack blocks or legacy +attachments. Native formatting markers remain intact; block-only messages do not +claim extracted block text. Files keep their independent extraction parts (capture +schema 1) or child records (schema 2). Other providers require their own explicit +body selection; field names alone do not establish native provenance. + +Artifacts now record native, extracted, or generated text provenance in their +existing generation JSON. Older descriptors without provenance retain their +prior interpretation: content boundary means extracted text, otherwise generated +text. They are not retroactively reclassified as native. + +Deploying this code does not rebuild existing publications. Use the existing +scoped reprojection plan/apply operation to increment the selected sync pipeline +version and replay retained originals. The version fence hides old publications +until their replacement is published. No provider recapture is required, and no +global runtime activation or version mutation is included in this change. diff --git a/docs/owned-sources/search-execution.md b/docs/owned-sources/search-execution.md new file mode 100644 index 0000000000..2a542084a7 --- /dev/null +++ b/docs/owned-sources/search-execution.md @@ -0,0 +1,186 @@ +# Owned search execution and performance evidence + +Owned search retrieves indexed originals. It never falls back to provider search. +Each request resolves authorized source connections, uses the unchanged 200-candidate +window per collection, checks exact publications before content leaves retrieval, +and rechecks source authentication and publications before returning results. + +Publication eligibility includes record/revision, pipeline version, generation, +non-retirement, parent visibility and indexed-part coverage. Batched tuple checks +share the visibility expression; an indexed body never authorizes an unsupported +attachment. Retained text reads use the same publication predicate. + +Query embeddings can be prepared once and reused across collections **within one +owned-search request**. Preparation belongs to the creating executor instance and +pins the primary query, ordered variations and retrieval strategy. A different +executor or changed query/strategy is rejected. Filters, ACL resolution, vector +queries and publication checks still run separately for each collection. Keyword +search never performs dense inference. There is no shared result, authorization, +query or embedding cache and no concurrent use of a database session. + +## Topology and limits + +Almanac's server-owned `CaptureTarget` chooses organization and collection for each +owner/provider/project. Provisioning does not require an organization or collection +per provider. Sources may share a collection, or use several collections in the +same organization. Organization is an authorization boundary; collection is an +index/deployment boundary. A provisioned source cannot silently change collection. + +The September 30 local pilot uses four isolated organizations, each with one +collection. Request-local embedding reuse does **not** reduce that pilot's four +embedding calls. The multi-collection SQL fixture qualifies reuse and preserves the +race fence; it is not evidence of deployed production topology or a new speedup +for the pilot. No corpus ownership or operator binding was remapped for this work. + +## Recorded local evidence + +Before/after batched publication checks, an actual-router Slack hybrid query for +`meeting` retained 200 candidates and fell from 2222 ms to 569 ms in two local +instrumented samples. Enrichment fell from 625 to 25 ms. Embedding and Vespa latency +varied between samples, so these are diagnostic samples, not a latency guarantee. + +A later full browser benchmark had one HTTP 503 whose body and duration were not +retained. The evaluation harness suppresses general logs to avoid private-content +logging, so the failing stage cannot be reconstructed. Both containers remained +running without OOM/restart. No cause-specific retry or timeout increase is justified +by that evidence. Safe status, exception type and elapsed-stage diagnostics are +being qualified separately in the Almanac adapter. + +The runtime measured here still used a request-owned database session that held +its connection across embedding/index waits after the first SQL query. The later +phase-owned implementation below addresses that ownership separately; this was +not established as the cause of the 503. + + +## Repeated actual API qualification after embedding reuse + +Six serial product requests each returned HTTP 200 and 25 results. Their wall times +were 1976, 836, 1408, 1202, 1210 and 3013 ms. Each fanned out to four organizations; +[24 sanitized stage samples](search-stage-samples.json) preserve measured evidence. +The slowest organization request per product request was: + +| Sample | Airweave total | Dense | Vespa adapter | Visibility | Enrichment | All SQL | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 1176 | 410 | 452 | 35 | 230 | 273 | +| 2 | 703 | 163 | 274 | 54 | 176 | 212 | +| 3 | 1334 | 74 | 189 | 883 | 170 | 1031 | +| 4 | 1094 | 176 | 416 | 122 | 331 | 381 | +| 5 | 1019 | 175 | 378 | 70 | 346 | 389 | +| 6 | 2815 | 131 | 2609 | 23 | 19 | 36 | + +All durations are milliseconds, measured as nested wall intervals; SQL overlaps +other stages and these columns must not be summed. No errors were recorded. +The sixth sample's delay was in the Vespa adapter await, which includes thread +queueing, HTTP, native engine work and event-loop scheduling. It is **not** evidence +of a PostgreSQL plan switch. Sample 3 independently had an 831 ms SQL fingerprint +interval. Neither measurement reconstructs the previous unlogged 503/13-second tail. + +## Diagnostic design + +The private instrumentation below measures worker start/end alongside the Vespa +adapter await and retains only numeric native response timing fields. It does not +log YQL, embeddings, results or identities or change ranking and candidate limits. + +The installed PyVespa `Vespa.query` creates a fresh `VespaSync` HTTP session per +call, then retains the full decoded response in `VespaQueryResponse.json`. This is +a source-level finding, not proof that connection setup causes the observed tail. +Vespa's [result-format documentation](https://docs.vespa.ai/en/reference/querying/default-result-format.html) +provides `querytime`, `summaryfetchtime` and `searchtime` in seconds when +`presentation.timing=true` is requested. A temporary diagnostic can copy the query +body and add only that presentation flag, retaining those three numeric values +alongside worker-entry/exit times. No query trace, response body or result content +needs to be logged. Pooling changes should wait for that decomposition. + + +## Worker and native Vespa decomposition + +A second instrumented candidate ran immutable commit +`e37d018de786d3b9446b31a100a8dcd1c4ae9009` on loopback port 18084. +Authentication qualification returned 401 without a key and 200 for an authorized +retained-record read. Almanac then switched its private origin to this candidate; +all six product requests returned 200 with 25 records and identical result-identity +digests. The original 18081 process was stopped only after this success. The candidate +continues serving the same isolated corpus; no provider fetch, schema migration or +operator identity remapping occurred. + +Product times were 2439, 922, 1333, 565, 660 and 1171 ms. +[24 sanitized worker samples](search-worker-samples.json) record native Vespa timing +and worker boundaries. The critical organization request per product request was: + +| Sample | Airweave total | Dense | Worker queue | SDK worker | Resume delay | Native Vespa search | All SQL | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 1702 | 703 | 0.1 | 900 | 0.2 | 509 | 47 | +| 2 | 818 | 161 | 3.3 | 358 | 0.2 | 96 | 226 | +| 3 | 1252 | 78 | 1.2 | 196 | 0.4 | 69 | 946 | +| 4 | 487 | 94 | 0.7 | 181 | 0.4 | 84 | 178 | +| 5 | 535 | 128 | 0.7 | 359 | 0.9 | 172 | 20 | +| 6 | 900 | 248 | 0.2 | 321 | 0.3 | 122 | 286 | + +Milliseconds; nested intervals again must not be summed. On these critical requests, +thread queueing and event-loop resumption are small. Native Vespa processing explains +part of SDK wall time; the remainder includes transport/session setup and JSON decode, +which were not measured separately. Sample 3 again contains a SQL delay. This does not +justify blaming thread starvation, choosing a database plan, or changing HTTP pooling +without a targeted test. Six successful samples neither establish a latency percentile +nor reconstruct the earlier unlogged 503 and 13-second response. + +Instrumentation lives only in the private launcher, preserving general log suppression. +Its copied query body adds `presentation.timing` only; result selection, ranking, +authorization and the 200-candidate bound remain unchanged. + +## Narrow enrichment projection + +Search enrichment now selects only the scalar identity/publication/date/completeness +fields used by a result card, plus the potential Gmail thread ID. A frozen typed row +replaces the live `Entity` ORM object. Native payloads and blob manifests are no longer +transferred or decoded into the search process merely to build cards. PostgreSQL may +still read the JSON value to extract the thread ID; this is not a claim of eliminating +all database JSON work or a measured latency improvement. + +The thread expression returns text only when the native JSON value is a string. +Numbers, objects, missing keys and JSON null remain unavailable. Existing provider, +record-kind and allowed-ID checks still apply. Exact publication predicates, indexed +part coverage, exclusions and the final fresh authorization check are unchanged. + +Projection qualification: 20 owned-search/visibility PostgreSQL tests passed against +an isolated archive of `a539e40979871f5ba23428b802845b5f1329f6c9` with only the +search implementation and its test file overlaid. Existing card checks now assert +identity, dates, completeness and excerpt parity and inspect actual DBAPI result +columns to reject transfer of full payloads/blob manifests. Thread-ID cases cover +missing/null/numeric/object/invalid strings and non-Gmail records. Existing stale, +foreign-source, unsupported-part and later-collection mutation fences also passed. +This is scoped correctness/resource-boundary qualification, not a measured speedup +or a claim that the candidate runtime has been updated with this change. + + +## Phase-owned search reads + +Owned search now closes its own authentication and initial source-scope read +transactions before embedding preparation. Each collection gets a fresh session: +it performs no SQL until indexed retrieval returns, then validates publication and +builds cards. That session closes before the next collection. A final fresh read +rechecks authorized sources, coverage and every exact publication. Only frozen typed +source snapshots cross phases. Source collection UUID and pipeline version are part +of both the scope comparison and final SQL fence, alongside source/account identity. +A same-name collection replacement or pipeline change cannot reuse the old routing. + +The dedicated non-yield dependency calls the existing context resolver. API-key +checks, Auth0 authentication, rate limiting and caching are unchanged. Closing its +owned session preserves the existing read route's rollback of Auth0's flushed +last-active update; this change does not silently commit it. Generic auth dependencies +and other endpoints remain unchanged. No borrowed session is rolled back or closed. + +The session factory is resolved dynamically from the configured database module. +Local evaluation must keep its isolated factory installed (or explicitly override +the new dependency); overriding only `get_db` is insufficient. Test fixtures override +both new dependencies. Runtime activation requires checking this boundary again. + +Qualification uses real PostgreSQL with pool size one and no overflow: the sole +connection is available while sparse inference or Vespa is paused, after cancellation +there, and after failure/cancellation once enrichment has acquired the connection. +Actual API-key/Auth0 SQL authentication also releases that pool and leaves persisted +last-active unchanged. Scope revocation, collection replacement and pipeline changes +during retrieval reject the response; existing later-collection record mutation +checks still prevent returning an earlier stale hit. Search/embedding transports are +fakes in these tests. This is a pool-ownership/correctness proof, not a measured +latency gain or a claim that the local browser runtime has been activated. diff --git a/docs/owned-sources/search-stage-samples.json b/docs/owned-sources/search-stage-samples.json new file mode 100644 index 0000000000..ce0417144c --- /dev/null +++ b/docs/owned-sources/search-stage-samples.json @@ -0,0 +1,2117 @@ +{ + "qualification": "September30 local actual API with request-scoped stage instrumentation; 24organizationrequests from6serialproductqueries; durations are nested wall time not additive CPU; no queries, source IDs, content or SQLparameters recorded. Candidatewindow200 retained.", + "requests": [ + { + "sequence": 3, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 20.981332985684276 + }, + "dense": { + "count": 1, + "ms": 416.2748340750113 + }, + "sparse": { + "count": 1, + "ms": 6.506791920401156 + }, + "prepare_query": { + "count": 1, + "ms": 422.8015000699088 + }, + "vespa": { + "count": 1, + "ms": 382.25970801431686 + }, + "visibility": { + "count": 1, + "ms": 140.25079098064452 + }, + "execute": { + "count": 1, + "ms": 524.2698750225827 + }, + "_enrich": { + "count": 1, + "ms": 91.03287500329316 + }, + "_coverage": { + "count": 1, + "ms": 9.96904110070318 + }, + "_final_publications": { + "count": 1, + "ms": 11.461625108495355 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 17.354625975713134 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.983167021535337 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 70.8857080899179 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 14.206208055838943 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 61.02733395528048 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 22.471540956757963 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 5.890500033274293 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.7412910927087069 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 5.720125045627356 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 1081.774792051874 + }, + { + "sequence": 2, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 27.059999061748385 + }, + "dense": { + "count": 1, + "ms": 423.87624992989004 + }, + "sparse": { + "count": 1, + "ms": 1.0307920165359974 + }, + "prepare_query": { + "count": 1, + "ms": 424.9693750170991 + }, + "vespa": { + "count": 1, + "ms": 559.3767920508981 + }, + "visibility": { + "count": 1, + "ms": 40.0237909052521 + }, + "execute": { + "count": 1, + "ms": 601.858958019875 + }, + "_enrich": { + "count": 1, + "ms": 58.30166698433459 + }, + "_coverage": { + "count": 1, + "ms": 26.148000033572316 + }, + "_final_publications": { + "count": 1, + "ms": 11.744874995201826 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 23.133749957196414 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 7.161208079196513 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 20.701125031337142 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 42.683916981332004 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 1.3428329257294536 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 18.984375055879354 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.0869590332731605 + }, + "2499200562431a3a": { + "count": 1, + "ms": 8.137083961628377 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 1150.5849999375641 + }, + { + "sequence": 1, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 24.168250034563243 + }, + "dense": { + "count": 1, + "ms": 415.1032909285277 + }, + "sparse": { + "count": 1, + "ms": 3.470625029876828 + }, + "prepare_query": { + "count": 1, + "ms": 419.2308330675587 + }, + "vespa": { + "count": 1, + "ms": 455.41983388829976 + }, + "visibility": { + "count": 1, + "ms": 65.13550004456192 + }, + "execute": { + "count": 1, + "ms": 526.5297499718145 + }, + "_enrich": { + "count": 1, + "ms": 73.77662509679794 + }, + "_coverage": { + "count": 1, + "ms": 104.42025004886091 + }, + "_final_publications": { + "count": 1, + "ms": 14.168875059112906 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 17.26112491451204 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 4.835000028833747 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 28.40045909397304 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 14.269707957282662 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 58.46558301709592 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 7.127500022761524 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 18.680915934965014 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.4107919996604323 + }, + "4860037db3700175": { + "count": 1, + "ms": 41.594209033064544 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 1.4456250937655568 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 1.257791998796165 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 9.199041058309376 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 7.366082980297506 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 1164.2424170859158 + }, + { + "sequence": 4, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 24.722332949750125 + }, + "dense": { + "count": 1, + "ms": 410.0976249901578 + }, + "sparse": { + "count": 1, + "ms": 6.485457997769117 + }, + "prepare_query": { + "count": 1, + "ms": 416.62533301860094 + }, + "vespa": { + "count": 1, + "ms": 452.3796249413863 + }, + "visibility": { + "count": 1, + "ms": 35.135333077050745 + }, + "execute": { + "count": 1, + "ms": 490.4897080268711 + }, + "_enrich": { + "count": 1, + "ms": 230.23374995682389 + }, + "_coverage": { + "count": 1, + "ms": 5.5731250904500484 + }, + "_final_publications": { + "count": 1, + "ms": 7.313958019949496 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 22.142291883938015 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 18.409666954539716 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 8.498082985170186 + }, + "227345276c4b5446": { + "count": 1, + "ms": 217.60816604364663 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.8341670036315918 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 1.3499170308932662 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.5095419473946095 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 3.139832988381386 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 1175.6089580012485 + }, + { + "sequence": 7, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 9.415167034603655 + }, + "dense": { + "count": 1, + "ms": 150.34812502563 + }, + "sparse": { + "count": 1, + "ms": 7.051291991956532 + }, + "prepare_query": { + "count": 1, + "ms": 158.213583054021 + }, + "vespa": { + "count": 1, + "ms": 205.83049999549985 + }, + "visibility": { + "count": 1, + "ms": 89.61008302867413 + }, + "execute": { + "count": 1, + "ms": 301.0749579407275 + }, + "_enrich": { + "count": 1, + "ms": 104.27737492136657 + }, + "_coverage": { + "count": 1, + "ms": 11.745374999009073 + }, + "_final_publications": { + "count": 1, + "ms": 36.33583406917751 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 7.525542052462697 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.370500074699521 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 15.718917013145983 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 54.76729106158018 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 91.03541704826057 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 1.473416923545301 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 4.532459075562656 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.223042025230825 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 26.070374995470047 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 623.4907500911504 + }, + { + "sequence": 8, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 11.766041163355112 + }, + "dense": { + "count": 1, + "ms": 153.2676670467481 + }, + "sparse": { + "count": 1, + "ms": 6.886459072120488 + }, + "prepare_query": { + "count": 1, + "ms": 160.18770798109472 + }, + "vespa": { + "count": 1, + "ms": 231.21958295814693 + }, + "visibility": { + "count": 1, + "ms": 81.94658404681832 + }, + "execute": { + "count": 1, + "ms": 317.49387504532933 + }, + "_enrich": { + "count": 1, + "ms": 71.11679099034518 + }, + "_coverage": { + "count": 1, + "ms": 47.35345905646682 + }, + "_final_publications": { + "count": 1, + "ms": 19.881334039382637 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 9.379792027175426 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 4.599625011906028 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 23.095999960787594 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 22.25341706071049 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 40.53116694558412 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 9.81604098342359 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 11.780292028561234 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.5035420181229711 + }, + "4860037db3700175": { + "count": 1, + "ms": 0.6775000365450978 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 2.9274170519784093 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 2.296374994330108 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 10.991000104695559 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 9.165249997749925 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 629.3531249975786 + }, + { + "sequence": 6, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 5.98758296109736 + }, + "dense": { + "count": 1, + "ms": 152.33945904765278 + }, + "sparse": { + "count": 1, + "ms": 11.915208073332906 + }, + "prepare_query": { + "count": 1, + "ms": 164.3058330519125 + }, + "vespa": { + "count": 1, + "ms": 358.3093340275809 + }, + "visibility": { + "count": 1, + "ms": 12.147084111347795 + }, + "execute": { + "count": 1, + "ms": 374.7342500137165 + }, + "_enrich": { + "count": 1, + "ms": 99.2774999467656 + }, + "_coverage": { + "count": 1, + "ms": 10.457874974235892 + }, + "_final_publications": { + "count": 1, + "ms": 8.07679200079292 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.4282091073691845 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.4807909717783332 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 5.371040897443891 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 56.48758402094245 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 29.85670801717788 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 5.245625041425228 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.6650830619037151 + }, + "2499200562431a3a": { + "count": 1, + "ms": 2.0548749016597867 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 663.4803750785068 + }, + { + "sequence": 5, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 7.251292001456022 + }, + "dense": { + "count": 1, + "ms": 162.91516704950482 + }, + "sparse": { + "count": 1, + "ms": 5.476542050018907 + }, + "prepare_query": { + "count": 1, + "ms": 168.45150000881404 + }, + "vespa": { + "count": 1, + "ms": 274.40424996893853 + }, + "visibility": { + "count": 1, + "ms": 54.09208405762911 + }, + "execute": { + "count": 1, + "ms": 332.5495000462979 + }, + "_enrich": { + "count": 1, + "ms": 175.91229197569191 + }, + "_coverage": { + "count": 1, + "ms": 9.203209076076746 + }, + "_final_publications": { + "count": 1, + "ms": 8.18145798984915 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 5.3002911154180765 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 21.253084065392613 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 18.066833959892392 + }, + "227345276c4b5446": { + "count": 1, + "ms": 161.16387501824647 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.8148750057443976 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 0.8872910402715206 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.3558750031515956 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 4.315625061281025 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 702.7063750429079 + }, + { + "sequence": 12, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.792165942490101 + }, + "dense": { + "count": 1, + "ms": 66.94124999921769 + }, + "sparse": { + "count": 1, + "ms": 1.9606249406933784 + }, + "prepare_query": { + "count": 1, + "ms": 68.97516606841236 + }, + "vespa": { + "count": 1, + "ms": 155.2103339927271 + }, + "visibility": { + "count": 1, + "ms": 929.0852500125766 + }, + "execute": { + "count": 1, + "ms": 1086.1585000529885 + }, + "_enrich": { + "count": 1, + "ms": 70.99999999627471 + }, + "_coverage": { + "count": 1, + "ms": 12.965750065632164 + }, + "_final_publications": { + "count": 1, + "ms": 15.56666602846235 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.2450839644297957 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.278917072340846 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 26.266500004567206 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 73.34062503650784 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 59.933540993370116 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 1.5460000140592456 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 2.7932500233873725 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 6.924125016666949 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 8.849709061905742 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 1259.9269580096006 + }, + { + "sequence": 9, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 2.710792003199458 + }, + "dense": { + "count": 1, + "ms": 69.73824999295175 + }, + "sparse": { + "count": 1, + "ms": 0.48016710206866264 + }, + "prepare_query": { + "count": 1, + "ms": 70.28787501621991 + }, + "vespa": { + "count": 1, + "ms": 1096.9839169410989 + }, + "visibility": { + "count": 1, + "ms": 39.961625006981194 + }, + "execute": { + "count": 1, + "ms": 1138.2223330438137 + }, + "_enrich": { + "count": 1, + "ms": 48.959000036120415 + }, + "_coverage": { + "count": 1, + "ms": 6.951833958737552 + }, + "_final_publications": { + "count": 1, + "ms": 7.558958022855222 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.493541058152914 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 7.188707939349115 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 22.2346669761464 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 23.249167017638683 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 17.297792015597224 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 3.2182090217247605 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.46629097778350115 + }, + "2499200562431a3a": { + "count": 1, + "ms": 3.5706249764189124 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 1275.4977500298992 + }, + { + "sequence": 11, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.292208071798086 + }, + "dense": { + "count": 1, + "ms": 62.33175005763769 + }, + "sparse": { + "count": 1, + "ms": 0.2817499917000532 + }, + "prepare_query": { + "count": 1, + "ms": 62.67604196909815 + }, + "vespa": { + "count": 1, + "ms": 1031.9742910796776 + }, + "visibility": { + "count": 1, + "ms": 102.05487499479204 + }, + "execute": { + "count": 1, + "ms": 1148.3783340081573 + }, + "_enrich": { + "count": 1, + "ms": 25.6517919478938 + }, + "_coverage": { + "count": 1, + "ms": 55.74112490285188 + }, + "_final_publications": { + "count": 1, + "ms": 17.10937498137355 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.4157919688150287 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.5292500611394644 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 63.26654099393636 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 23.13979109749198 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 15.811291057616472 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 2.5095000164583325 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 10.878624976612628 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 6.8604579428210855 + }, + "4860037db3700175": { + "count": 1, + "ms": 22.120374953374267 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.6761249387636781 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 0.2955840900540352 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 2.76004197075963 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 10.070209042169154 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 1313.444250030443 + }, + { + "sequence": 10, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 2.544791088439524 + }, + "dense": { + "count": 1, + "ms": 74.00362507905811 + }, + "sparse": { + "count": 1, + "ms": 0.7005829829722643 + }, + "prepare_query": { + "count": 1, + "ms": 74.76600003428757 + }, + "vespa": { + "count": 1, + "ms": 188.86408396065235 + }, + "visibility": { + "count": 1, + "ms": 883.0980840139091 + }, + "execute": { + "count": 1, + "ms": 1073.9188329316676 + }, + "_enrich": { + "count": 1, + "ms": 170.41695804800838 + }, + "_coverage": { + "count": 1, + "ms": 5.164291942492127 + }, + "_final_publications": { + "count": 1, + "ms": 6.403250037692487 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.664999988861382 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 831.1943749431521 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 34.70541606657207 + }, + "227345276c4b5446": { + "count": 1, + "ms": 158.93658401910216 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.985874910838902 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 1.1015419149771333 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.17662497702986002 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 2.5326249888166785 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 1333.68475001771 + }, + { + "sequence": 15, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.080042893998325 + }, + "dense": { + "count": 1, + "ms": 165.07424996234477 + }, + "sparse": { + "count": 1, + "ms": 1.0408329544588923 + }, + "prepare_query": { + "count": 1, + "ms": 166.30387492477894 + }, + "vespa": { + "count": 1, + "ms": 440.48049999400973 + }, + "visibility": { + "count": 1, + "ms": 179.47479209396988 + }, + "execute": { + "count": 1, + "ms": 621.7332079540938 + }, + "_enrich": { + "count": 1, + "ms": 100.10841605253518 + }, + "_coverage": { + "count": 1, + "ms": 15.588499954901636 + }, + "_final_publications": { + "count": 1, + "ms": 36.27545898780227 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.9191669998690486 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 70.07212506141514 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 36.27354197669774 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 52.24266601726413 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 76.24662504531443 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 2.1802500123158097 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 9.423875017091632 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.6631249561905861 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 30.16150009352714 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 944.0113340970129 + }, + { + "sequence": 16, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 6.74495892599225 + }, + "dense": { + "count": 1, + "ms": 174.3142909836024 + }, + "sparse": { + "count": 1, + "ms": 0.6041250890120864 + }, + "prepare_query": { + "count": 1, + "ms": 174.9319169903174 + }, + "vespa": { + "count": 1, + "ms": 501.8192919669673 + }, + "visibility": { + "count": 1, + "ms": 64.14791697170585 + }, + "execute": { + "count": 1, + "ms": 577.1721249911934 + }, + "_enrich": { + "count": 1, + "ms": 110.81891704816371 + }, + "_coverage": { + "count": 1, + "ms": 81.40800008550286 + }, + "_final_publications": { + "count": 1, + "ms": 48.73295803554356 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 4.563168040476739 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.170082949101925 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 23.009750060737133 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 10.039665969088674 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 80.65083296969533 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 3.7403330206871033 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 20.194957964122295 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 3.3077499829232693 + }, + "4860037db3700175": { + "count": 1, + "ms": 0.883165979757905 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.7463330402970314 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 8.105917018838227 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 16.201584017835557 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 35.00854200683534 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 1001.1217090068385 + }, + { + "sequence": 13, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 24.764375062659383 + }, + "dense": { + "count": 1, + "ms": 175.05683400668204 + }, + "sparse": { + "count": 1, + "ms": 1.1266249930486083 + }, + "prepare_query": { + "count": 1, + "ms": 176.25720903743058 + }, + "vespa": { + "count": 1, + "ms": 607.9739999258891 + }, + "visibility": { + "count": 1, + "ms": 43.512500007636845 + }, + "execute": { + "count": 1, + "ms": 653.4734159940854 + }, + "_enrich": { + "count": 1, + "ms": 96.6626669978723 + }, + "_coverage": { + "count": 1, + "ms": 22.702542017214 + }, + "_final_publications": { + "count": 1, + "ms": 35.11658392380923 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 22.74199912790209 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 16.115250065922737 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 8.703375002369285 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 70.47658402007073 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 8.543459000065923 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 14.393875026144087 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 2.33279203530401 + }, + "2499200562431a3a": { + "count": 1, + "ms": 11.552125099115074 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 1009.7570419311523 + }, + { + "sequence": 14, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.7359159905463457 + }, + "dense": { + "count": 1, + "ms": 175.78433302696794 + }, + "sparse": { + "count": 1, + "ms": 0.9996249573305249 + }, + "prepare_query": { + "count": 1, + "ms": 176.8083330243826 + }, + "vespa": { + "count": 1, + "ms": 416.2487080320716 + }, + "visibility": { + "count": 1, + "ms": 122.3345419857651 + }, + "execute": { + "count": 1, + "ms": 540.0912909535691 + }, + "_enrich": { + "count": 1, + "ms": 331.29774988628924 + }, + "_coverage": { + "count": 1, + "ms": 15.635541989468038 + }, + "_final_publications": { + "count": 1, + "ms": 25.462583056651056 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.1389579633250833 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 9.843125008046627 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 47.68558300565928 + }, + "227345276c4b5446": { + "count": 1, + "ms": 309.52387501019984 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 3.208791953511536 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 1.7917499644681811 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.4837081069126725 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 6.455249967984855 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 1093.6309170210734 + }, + { + "sequence": 19, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 22.333709057420492 + }, + "dense": { + "count": 1, + "ms": 150.0156670808792 + }, + "sparse": { + "count": 1, + "ms": 11.587167042307556 + }, + "prepare_query": { + "count": 1, + "ms": 162.66179201193154 + }, + "vespa": { + "count": 1, + "ms": 357.17754205688834 + }, + "visibility": { + "count": 1, + "ms": 129.57099999766797 + }, + "execute": { + "count": 1, + "ms": 496.22041604015976 + }, + "_enrich": { + "count": 1, + "ms": 178.12020890414715 + }, + "_coverage": { + "count": 1, + "ms": 14.941166038624942 + }, + "_final_publications": { + "count": 1, + "ms": 35.05670896265656 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 19.898416940122843 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 21.18158305529505 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 17.832041950896382 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 48.60466602258384 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 139.66970809269696 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 23.935875040479004 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 7.616457995027304 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.9968749945983291 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 20.423874957486987 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 910.839542048052 + }, + { + "sequence": 18, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 10.032958001829684 + }, + "dense": { + "count": 1, + "ms": 178.3760410035029 + }, + "sparse": { + "count": 1, + "ms": 0.5773329176008701 + }, + "prepare_query": { + "count": 1, + "ms": 178.99479204788804 + }, + "vespa": { + "count": 1, + "ms": 454.5237079728395 + }, + "visibility": { + "count": 1, + "ms": 105.22020899225026 + }, + "execute": { + "count": 1, + "ms": 571.801791083999 + }, + "_enrich": { + "count": 1, + "ms": 61.847709002904594 + }, + "_coverage": { + "count": 1, + "ms": 100.27866705786437 + }, + "_final_publications": { + "count": 1, + "ms": 22.4263749551028 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 4.487082944251597 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 20.635874941945076 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 34.77570798713714 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 22.953292005695403 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 43.44141704495996 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 3.7646250566467643 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 15.19566704519093 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.352582941763103 + }, + "4860037db3700175": { + "count": 1, + "ms": 2.4471249198541045 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.8779580239206553 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 3.8167500169947743 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 21.30549994762987 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 10.47720795031637 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 946.5210839407519 + }, + { + "sequence": 17, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.191416966728866 + }, + "dense": { + "count": 1, + "ms": 181.75150000024587 + }, + "sparse": { + "count": 1, + "ms": 19.68583301641047 + }, + "prepare_query": { + "count": 1, + "ms": 201.46366697736084 + }, + "vespa": { + "count": 1, + "ms": 509.1138749849051 + }, + "visibility": { + "count": 1, + "ms": 35.40741698816419 + }, + "execute": { + "count": 1, + "ms": 565.984082990326 + }, + "_enrich": { + "count": 1, + "ms": 189.30870899930596 + }, + "_coverage": { + "count": 1, + "ms": 18.03158305119723 + }, + "_final_publications": { + "count": 1, + "ms": 8.639499894343317 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.113042002543807 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 6.577042047865689 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 12.31124997138977 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 155.43862502090633 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 14.549666084349155 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 7.87733297329396 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.5354579770937562 + }, + "2499200562431a3a": { + "count": 1, + "ms": 3.00191598944366 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 987.1257080230862 + }, + { + "sequence": 20, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 6.4297078642994165 + }, + "dense": { + "count": 1, + "ms": 175.3441250184551 + }, + "sparse": { + "count": 1, + "ms": 19.589458010159433 + }, + "prepare_query": { + "count": 1, + "ms": 194.98054194264114 + }, + "vespa": { + "count": 1, + "ms": 377.5235840585083 + }, + "visibility": { + "count": 1, + "ms": 70.01120899803936 + }, + "execute": { + "count": 1, + "ms": 449.16333397850394 + }, + "_enrich": { + "count": 1, + "ms": 345.7779579330236 + }, + "_coverage": { + "count": 1, + "ms": 6.964874919503927 + }, + "_final_publications": { + "count": 1, + "ms": 14.39250004477799 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 5.538332974538207 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 31.704834080301225 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 12.205415987409651 + }, + "227345276c4b5446": { + "count": 1, + "ms": 332.4839168926701 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.7360000163316727 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 2.4184579961001873 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.4141249228268862 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 3.7707919254899025 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 1019.4548750296235 + }, + { + "sequence": 24, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.785124910995364 + }, + "dense": { + "count": 1, + "ms": 138.52658297400922 + }, + "sparse": { + "count": 1, + "ms": 0.21062500309199095 + }, + "prepare_query": { + "count": 1, + "ms": 138.77249998040497 + }, + "vespa": { + "count": 1, + "ms": 1715.635541942902 + }, + "visibility": { + "count": 1, + "ms": 132.22104194574058 + }, + "execute": { + "count": 1, + "ms": 1849.270167062059 + }, + "_enrich": { + "count": 1, + "ms": 52.795750088989735 + }, + "_coverage": { + "count": 1, + "ms": 26.668832986615598 + }, + "_final_publications": { + "count": 1, + "ms": 10.617665946483612 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.2279999684542418 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 10.033750091679394 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 41.89637501258403 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 34.7755829570815 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 31.50499996263534 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 2.8527920367196202 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 21.01708296686411 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.4943750100210309 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 6.05637498665601 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 2083.523500012234 + }, + { + "sequence": 23, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.943749936297536 + }, + "dense": { + "count": 1, + "ms": 136.26425003167242 + }, + "sparse": { + "count": 1, + "ms": 3.064083051867783 + }, + "prepare_query": { + "count": 1, + "ms": 139.38750000670552 + }, + "vespa": { + "count": 1, + "ms": 1803.4203751012683 + }, + "visibility": { + "count": 1, + "ms": 73.67808290291578 + }, + "execute": { + "count": 1, + "ms": 1880.1395420450717 + }, + "_enrich": { + "count": 1, + "ms": 49.1235830122605 + }, + "_coverage": { + "count": 1, + "ms": 57.574499980546534 + }, + "_final_publications": { + "count": 1, + "ms": 12.067666975781322 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.7562910690903664 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 14.353249920532107 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 19.084625062532723 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 24.221083032898605 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 26.21775004081428 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 7.67866603564471 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 9.051040979102254 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.0478750336915255 + }, + "4860037db3700175": { + "count": 1, + "ms": 16.732458025217056 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 3.302415949292481 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 2.9357500607147813 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 6.882583140395582 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 8.687541936524212 + } + }, + "errors": [], + "candidates": 200, + "total_ms": 2142.9362499620765 + }, + { + "sequence": 21, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 10.725875967182219 + }, + "dense": { + "count": 1, + "ms": 163.01108396146446 + }, + "sparse": { + "count": 1, + "ms": 0.859708059579134 + }, + "prepare_query": { + "count": 1, + "ms": 164.00095797143877 + }, + "vespa": { + "count": 1, + "ms": 1751.5132919652387 + }, + "visibility": { + "count": 1, + "ms": 63.263374962843955 + }, + "execute": { + "count": 1, + "ms": 1816.254833014682 + }, + "_enrich": { + "count": 1, + "ms": 139.0849999152124 + }, + "_coverage": { + "count": 1, + "ms": 7.946375058963895 + }, + "_final_publications": { + "count": 1, + "ms": 10.63124998472631 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 6.748125073499978 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 5.06999995559454 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 30.09774989914149 + }, + "227345276c4b5446": { + "count": 1, + "ms": 124.29991597309709 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 6.123207975178957 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 3.836291958577931 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.8284159703180194 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 5.511333933100104 + } + }, + "errors": [], + "candidates": 196, + "total_ms": 2149.137625005096 + }, + { + "sequence": 22, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.825417068786919 + }, + "dense": { + "count": 1, + "ms": 130.95570902805775 + }, + "sparse": { + "count": 1, + "ms": 4.648332949727774 + }, + "prepare_query": { + "count": 1, + "ms": 135.95612498465925 + }, + "vespa": { + "count": 1, + "ms": 2608.8416669517756 + }, + "visibility": { + "count": 1, + "ms": 22.659041918814182 + }, + "execute": { + "count": 1, + "ms": 2633.3210000302643 + }, + "_enrich": { + "count": 1, + "ms": 18.839332973584533 + }, + "_coverage": { + "count": 1, + "ms": 10.801083990372717 + }, + "_final_publications": { + "count": 1, + "ms": 10.405333014205098 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.020083880983293 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.6238749483600259 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 14.410667004995048 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 8.465667022392154 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 0.7834999123588204 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 3.1355000101029873 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.3542909398674965 + }, + "2499200562431a3a": { + "count": 1, + "ms": 5.137000000104308 + } + }, + "errors": [], + "candidates": 198, + "total_ms": 2814.926875056699 + } + ] +} diff --git a/docs/owned-sources/search-worker-samples.json b/docs/owned-sources/search-worker-samples.json new file mode 100644 index 0000000000..1953567584 --- /dev/null +++ b/docs/owned-sources/search-worker-samples.json @@ -0,0 +1,2454 @@ +{ + "immutable_revision": "e37d018de786d3b9446b31a100a8dcd1c4ae9009", + "qualification": "Actual API on loopback18084 from immutable archive, 6serialproductqueries/24organizationrequests. Numeric stage/native timings only; no queries, source identifiers, SQLparameters or resultcontent. Intervals are nested walltime, not additiveCPU.", + "requests": [ + { + "sequence": 4, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 14.39700007904321 + }, + "dense": { + "count": 1, + "ms": 697.7981670061126 + }, + "sparse": { + "count": 1, + "ms": 6.554250023327768 + }, + "prepare_query": { + "count": 1, + "ms": 704.3882500147447 + }, + "vespa": { + "count": 1, + "ms": 652.7667089831084 + }, + "visibility": { + "count": 1, + "ms": 92.36441599205136 + }, + "execute": { + "count": 1, + "ms": 748.8067910308018 + }, + "_enrich": { + "count": 1, + "ms": 42.51174989622086 + }, + "_coverage": { + "count": 1, + "ms": 27.212833054363728 + }, + "_final_publications": { + "count": 1, + "ms": 14.53358307480812 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 13.119000010192394 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 3.1570419669151306 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 54.84887503553182 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 14.02979192789644 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 13.554790988564491 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 21.54762507416308 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 14.26812494173646 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 9.441999951377511 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 6.412832997739315 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 714.5442909095436, + "thread_submit_ms": 714.5484999055043, + "worker_enter_ms": 719.8180829873309, + "worker_exit_ms": 1364.0451659448445, + "await_resume_ms": 1364.1532909823582, + "adapter_return_ms": 1367.3077079001814 + }, + "vespa_native_seconds": { + "querytime": 0.244, + "summaryfetchtime": 0.082, + "searchtime": 0.333 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 1552.3313329322264 + }, + { + "sequence": 1, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 20.058708963915706 + }, + "dense": { + "count": 1, + "ms": 691.7723750229925 + }, + "sparse": { + "count": 1, + "ms": 13.597415992990136 + }, + "prepare_query": { + "count": 1, + "ms": 705.7115830248222 + }, + "vespa": { + "count": 1, + "ms": 688.2408329984173 + }, + "visibility": { + "count": 1, + "ms": 29.30437494069338 + }, + "execute": { + "count": 1, + "ms": 721.8387499451637 + }, + "_enrich": { + "count": 1, + "ms": 102.58941701613367 + }, + "_coverage": { + "count": 1, + "ms": 13.002125080674887 + }, + "_final_publications": { + "count": 1, + "ms": 7.20037508290261 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 5.277417018078268 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 12.694583041593432 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 8.783917059190571 + }, + "227345276c4b5446": { + "count": 1, + "ms": 79.17404198087752 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 11.609292007051408 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 9.656250011175871 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.16062508802860975 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 4.063541069626808 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 731.3685000408441, + "thread_submit_ms": 731.857500039041, + "worker_enter_ms": 732.0340829901397, + "worker_exit_ms": 1416.5454170433804, + "await_resume_ms": 1416.772291995585, + "adapter_return_ms": 1419.607250019908 + }, + "vespa_native_seconds": { + "querytime": 0.23, + "summaryfetchtime": 0.10400000000000001, + "searchtime": 0.336 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 1573.6422500340268 + }, + { + "sequence": 3, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 8.467874024063349 + }, + "dense": { + "count": 1, + "ms": 699.1888750344515 + }, + "sparse": { + "count": 1, + "ms": 6.565375020727515 + }, + "prepare_query": { + "count": 1, + "ms": 705.7651670183986 + }, + "vespa": { + "count": 1, + "ms": 678.466665907763 + }, + "visibility": { + "count": 1, + "ms": 61.461207922548056 + }, + "execute": { + "count": 1, + "ms": 743.8884590519592 + }, + "_enrich": { + "count": 1, + "ms": 35.72583303321153 + }, + "_coverage": { + "count": 1, + "ms": 114.05433295294642 + }, + "_final_publications": { + "count": 1, + "ms": 21.25195797998458 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 6.0969998594373465 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 9.712875005789101 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 31.63708304055035 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 5.438291002064943 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 12.6727499300614 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 12.342790956608951 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 18.270791973918676 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 2.7705830289050937 + }, + "4860037db3700175": { + "count": 1, + "ms": 40.20195791963488 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 1.977749983780086 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 2.1070840302854776 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 4.079291946254671 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 15.472374972887337 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 715.1667090365663, + "thread_submit_ms": 715.1694999774918, + "worker_enter_ms": 720.6202920060605, + "worker_exit_ms": 1390.5022089602426, + "await_resume_ms": 1390.915249940008, + "adapter_return_ms": 1393.631541985087 + }, + "vespa_native_seconds": { + "querytime": 0.23800000000000002, + "summaryfetchtime": 0.101, + "searchtime": 0.341 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 1629.7802090412006 + }, + { + "sequence": 2, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 7.911792024970055 + }, + "dense": { + "count": 1, + "ms": 702.8400419512764 + }, + "sparse": { + "count": 1, + "ms": 12.906166957691312 + }, + "prepare_query": { + "count": 1, + "ms": 715.8160830149427 + }, + "vespa": { + "count": 1, + "ms": 905.7754579698667 + }, + "visibility": { + "count": 1, + "ms": 24.27075000014156 + }, + "execute": { + "count": 1, + "ms": 930.7643749052659 + }, + "_enrich": { + "count": 1, + "ms": 25.458791060373187 + }, + "_coverage": { + "count": 1, + "ms": 12.371707940474153 + }, + "_final_publications": { + "count": 1, + "ms": 8.654584060423076 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 6.06404198333621 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 4.465624922886491 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 10.673750075511634 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 17.301541985943913 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 1.156416954472661 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 4.220000002533197 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.218083081766963 + }, + "2499200562431a3a": { + "count": 1, + "ms": 2.209833008237183 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 722.4849170306697, + "thread_submit_ms": 722.493875073269, + "worker_enter_ms": 722.5975840119645, + "worker_exit_ms": 1623.0179590638727, + "await_resume_ms": 1623.1887920293957, + "adapter_return_ms": 1628.2583340071142 + }, + "vespa_native_seconds": { + "querytime": 0.257, + "summaryfetchtime": 0.249, + "searchtime": 0.509 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 1701.5393340261653 + }, + { + "sequence": 5, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 6.998000084422529 + }, + "dense": { + "count": 1, + "ms": 161.28495789598674 + }, + "sparse": { + "count": 1, + "ms": 3.144333022646606 + }, + "prepare_query": { + "count": 1, + "ms": 164.9724580347538 + }, + "vespa": { + "count": 1, + "ms": 342.02920901589096 + }, + "visibility": { + "count": 1, + "ms": 65.0430410169065 + }, + "execute": { + "count": 1, + "ms": 409.411457949318 + }, + "_enrich": { + "count": 1, + "ms": 89.88524996675551 + }, + "_coverage": { + "count": 1, + "ms": 13.895208016037941 + }, + "_final_publications": { + "count": 1, + "ms": 26.216291007585824 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 4.420625977218151 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 7.723249960690737 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 27.281332993879914 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 10.08245802950114 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 65.84966601803899 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 14.081792091019452 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 5.074832937680185 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.9725000020116568 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 13.51004105526954 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 172.04616591334343, + "thread_submit_ms": 172.42312501184642, + "worker_enter_ms": 173.29445795621723, + "worker_exit_ms": 509.4542499864474, + "await_resume_ms": 509.67304094228894, + "adapter_return_ms": 514.0712499851361 + }, + "vespa_native_seconds": { + "querytime": 0.076, + "summaryfetchtime": 0.039, + "searchtime": 0.116 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 712.3994579305872 + }, + { + "sequence": 8, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 15.251790988259017 + }, + "dense": { + "count": 1, + "ms": 154.03837501071393 + }, + "sparse": { + "count": 1, + "ms": 4.9700409872457385 + }, + "prepare_query": { + "count": 1, + "ms": 159.11116695497185 + }, + "vespa": { + "count": 1, + "ms": 448.9807500503957 + }, + "visibility": { + "count": 1, + "ms": 35.94641690142453 + }, + "execute": { + "count": 1, + "ms": 488.1273750215769 + }, + "_enrich": { + "count": 1, + "ms": 70.2489159302786 + }, + "_coverage": { + "count": 1, + "ms": 39.53712503425777 + }, + "_final_publications": { + "count": 1, + "ms": 14.14441701490432 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 12.769749970175326 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.978458090685308 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 5.436790990643203 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 16.408832976594567 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 58.04041598457843 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 2.911832998506725 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 7.438958971761167 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 10.581750073470175 + }, + "4860037db3700175": { + "count": 1, + "ms": 0.4955410258844495 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.39175001438707113 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 0.6935000419616699 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 2.578666084446013 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 7.482332992367446 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 169.43408292718232, + "thread_submit_ms": 169.43954199086875, + "worker_enter_ms": 172.78091702610254, + "worker_exit_ms": 608.765707933344, + "await_resume_ms": 612.2454999713227, + "adapter_return_ms": 618.4113329509273 + }, + "vespa_native_seconds": { + "querytime": 0.07200000000000001, + "summaryfetchtime": 0.036000000000000004, + "searchtime": 0.11 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 787.4784169252962 + }, + { + "sequence": 6, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.894750076346099 + }, + "dense": { + "count": 1, + "ms": 161.17279091849923 + }, + "sparse": { + "count": 1, + "ms": 6.957000005058944 + }, + "prepare_query": { + "count": 1, + "ms": 168.1465000147 + }, + "vespa": { + "count": 1, + "ms": 480.32024991698563 + }, + "visibility": { + "count": 1, + "ms": 36.98929201345891 + }, + "execute": { + "count": 1, + "ms": 527.5187079096213 + }, + "_enrich": { + "count": 1, + "ms": 42.28266607969999 + }, + "_coverage": { + "count": 1, + "ms": 24.55241698771715 + }, + "_final_publications": { + "count": 1, + "ms": 22.843292099423707 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.8234589155763388 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 11.09312498010695 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 20.848499960266054 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 31.272124964743853 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 1.2763330014422536 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 10.02533407881856 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 5.498540936969221 + }, + "2499200562431a3a": { + "count": 1, + "ms": 17.50499999616295 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 175.64520810265094, + "thread_submit_ms": 175.67504104226828, + "worker_enter_ms": 179.79354108683765, + "worker_exit_ms": 647.8493330068886, + "await_resume_ms": 650.1307500293478, + "adapter_return_ms": 655.9629580006003 + }, + "vespa_native_seconds": { + "querytime": 0.075, + "summaryfetchtime": 0.098, + "searchtime": 0.17500000000000002 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 791.4707080926746 + }, + { + "sequence": 7, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.7414170801639557 + }, + "dense": { + "count": 1, + "ms": 161.01354197598994 + }, + "sparse": { + "count": 1, + "ms": 4.646750050596893 + }, + "prepare_query": { + "count": 1, + "ms": 165.6768750399351 + }, + "vespa": { + "count": 1, + "ms": 386.19487499818206 + }, + "visibility": { + "count": 1, + "ms": 40.67708400543779 + }, + "execute": { + "count": 1, + "ms": 430.91362493578345 + }, + "_enrich": { + "count": 1, + "ms": 204.53729096334428 + }, + "_coverage": { + "count": 1, + "ms": 4.470500047318637 + }, + "_final_publications": { + "count": 1, + "ms": 8.46066593658179 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.8975419225171208 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 6.736875046044588 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 23.642083979211748 + }, + "227345276c4b5446": { + "count": 1, + "ms": 188.68970894254744 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.9001250145956874 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 0.9309579618275166 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.2186669735237956 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 2.6028340216726065 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 170.5259169684723, + "thread_submit_ms": 170.5344170331955, + "worker_enter_ms": 173.81800001021475, + "worker_exit_ms": 532.1142920292914, + "await_resume_ms": 532.2840840090066, + "adapter_return_ms": 556.7168339621276 + }, + "vespa_native_seconds": { + "querytime": 0.067, + "summaryfetchtime": 0.026000000000000002, + "searchtime": 0.096 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 818.2105419691652 + }, + { + "sequence": 12, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 8.565208176150918 + }, + "dense": { + "count": 1, + "ms": 76.52866700664163 + }, + "sparse": { + "count": 1, + "ms": 0.7226669695228338 + }, + "prepare_query": { + "count": 1, + "ms": 77.32437504455447 + }, + "vespa": { + "count": 1, + "ms": 174.11716596689075 + }, + "visibility": { + "count": 1, + "ms": 34.63616594672203 + }, + "execute": { + "count": 1, + "ms": 212.0330409379676 + }, + "_enrich": { + "count": 1, + "ms": 710.2265419671312 + }, + "_coverage": { + "count": 1, + "ms": 20.33891691826284 + }, + "_final_publications": { + "count": 1, + "ms": 33.103249967098236 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 4.8387921415269375 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.34675002098083496 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 5.06216601934284 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 20.76575008686632 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 687.4182500177994 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 14.593999949283898 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 10.954124969430268 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 4.585916991345584 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 23.892374942079186 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 85.20262502133846, + "thread_submit_ms": 85.29004093725234, + "worker_enter_ms": 89.04145797714591, + "worker_exit_ms": 255.55124995298684, + "await_resume_ms": 255.90416602790356, + "adapter_return_ms": 259.317290969193 + }, + "vespa_native_seconds": { + "querytime": 0.041, + "summaryfetchtime": 0.019, + "searchtime": 0.061 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 1062.9791660467163 + }, + { + "sequence": 10, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 21.33975096512586 + }, + "dense": { + "count": 1, + "ms": 75.94337500631809 + }, + "sparse": { + "count": 1, + "ms": 1.6167500289157033 + }, + "prepare_query": { + "count": 1, + "ms": 77.60191604029387 + }, + "vespa": { + "count": 1, + "ms": 194.8804589919746 + }, + "visibility": { + "count": 1, + "ms": 34.99312500935048 + }, + "execute": { + "count": 1, + "ms": 231.5204170299694 + }, + "_enrich": { + "count": 1, + "ms": 685.0154160056263 + }, + "_coverage": { + "count": 1, + "ms": 67.61808297596872 + }, + "_final_publications": { + "count": 1, + "ms": 71.81354099884629 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 17.993916990235448 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 6.8410830572247505 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 10.948791052214801 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 8.314082981087267 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 668.7928329920396 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 8.747541927732527 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 9.087041020393372 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 8.88729200232774 + }, + "4860037db3700175": { + "count": 1, + "ms": 3.888124949298799 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 2.902667038142681 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 10.116999968886375 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 5.833208095282316 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 16.19304099585861 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 85.22920799441636, + "thread_submit_ms": 85.25708294473588, + "worker_enter_ms": 85.50212497357279, + "worker_exit_ms": 273.845124989748, + "await_resume_ms": 277.19900000374764, + "adapter_return_ms": 280.1077499752864 + }, + "vespa_native_seconds": { + "querytime": 0.026000000000000002, + "summaryfetchtime": 0.035, + "searchtime": 0.063 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 1157.5952499406412 + }, + { + "sequence": 9, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 8.465541061013937 + }, + "dense": { + "count": 1, + "ms": 104.20608299318701 + }, + "sparse": { + "count": 1, + "ms": 0.9832920040935278 + }, + "prepare_query": { + "count": 1, + "ms": 105.32324993982911 + }, + "vespa": { + "count": 1, + "ms": 881.9307920057327 + }, + "visibility": { + "count": 1, + "ms": 29.598708963021636 + }, + "execute": { + "count": 1, + "ms": 912.5305000925437 + }, + "_enrich": { + "count": 1, + "ms": 181.9128750357777 + }, + "_coverage": { + "count": 1, + "ms": 16.407124930992723 + }, + "_final_publications": { + "count": 1, + "ms": 10.464500053785741 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 5.84354205057025 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 3.6404170095920563 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 17.10862503387034 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 165.6676250277087 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 4.445333033800125 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 10.916332947090268 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.9451670339331031 + }, + "2499200562431a3a": { + "count": 1, + "ms": 3.2458750065416098 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 107.84874996170402, + "thread_submit_ms": 107.86354192532599, + "worker_enter_ms": 107.92920901440084, + "worker_exit_ms": 987.0548750041053, + "await_resume_ms": 987.5456669833511, + "adapter_return_ms": 989.7763750050217 + }, + "vespa_native_seconds": { + "querytime": 0.022, + "summaryfetchtime": 0.078, + "searchtime": 0.101 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 1236.1256669973955 + }, + { + "sequence": 11, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 5.712416023015976 + }, + "dense": { + "count": 1, + "ms": 78.43829190824181 + }, + "sparse": { + "count": 1, + "ms": 0.6155420560389757 + }, + "prepare_query": { + "count": 1, + "ms": 79.08908405806869 + }, + "vespa": { + "count": 1, + "ms": 199.77108296006918 + }, + "visibility": { + "count": 1, + "ms": 24.06924997922033 + }, + "execute": { + "count": 1, + "ms": 224.90783303510398 + }, + "_enrich": { + "count": 1, + "ms": 929.6094160526991 + }, + "_coverage": { + "count": 1, + "ms": 4.354374948889017 + }, + "_final_publications": { + "count": 1, + "ms": 7.863500039093196 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.8265829207375646 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 4.591165925376117 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 10.602999944239855 + }, + "227345276c4b5446": { + "count": 1, + "ms": 922.780166962184 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.7919160416349769 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 1.0205419966951013 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.21600001491606236 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 1.944832969456911 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 84.82829201966524, + "thread_submit_ms": 84.83520802110434, + "worker_enter_ms": 86.03083295747638, + "worker_exit_ms": 282.12995803914964, + "await_resume_ms": 282.57870802190155, + "adapter_return_ms": 284.59716704674065 + }, + "vespa_native_seconds": { + "querytime": 0.034, + "summaryfetchtime": 0.034, + "searchtime": 0.069 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 1252.1563329501078 + }, + { + "sequence": 14, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 8.505958947353065 + }, + "dense": { + "count": 1, + "ms": 77.20608299132437 + }, + "sparse": { + "count": 1, + "ms": 1.3491669669747353 + }, + "prepare_query": { + "count": 1, + "ms": 78.60499992966652 + }, + "vespa": { + "count": 1, + "ms": 185.96895900554955 + }, + "visibility": { + "count": 1, + "ms": 78.58295796904713 + }, + "execute": { + "count": 1, + "ms": 266.189958085306 + }, + "_enrich": { + "count": 1, + "ms": 36.97695804294199 + }, + "_coverage": { + "count": 1, + "ms": 7.01791699975729 + }, + "_final_publications": { + "count": 1, + "ms": 19.737666007131338 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 7.61345901992172 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.6217500194907188 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 11.499374988488853 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 43.80200000014156 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 23.803792078979313 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 1.8334579654037952 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 3.497124998830259 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.4103750688955188 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 13.730291975662112 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 81.73179102595896, + "thread_submit_ms": 81.75112504977733, + "worker_enter_ms": 81.90645801369101, + "worker_exit_ms": 264.2532910685986, + "await_resume_ms": 264.5625410368666, + "adapter_return_ms": 267.6987500162795 + }, + "vespa_native_seconds": { + "querytime": 0.044, + "summaryfetchtime": 0.027, + "searchtime": 0.076 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 417.4483750248328 + }, + { + "sequence": 16, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.9521249709650874 + }, + "dense": { + "count": 1, + "ms": 79.67091700993478 + }, + "sparse": { + "count": 1, + "ms": 3.3190420363098383 + }, + "prepare_query": { + "count": 1, + "ms": 83.04604096338153 + }, + "vespa": { + "count": 1, + "ms": 213.26254203449935 + }, + "visibility": { + "count": 1, + "ms": 49.71575003582984 + }, + "execute": { + "count": 1, + "ms": 278.72954204212874 + }, + "_enrich": { + "count": 1, + "ms": 28.903541970066726 + }, + "_coverage": { + "count": 1, + "ms": 56.68974993750453 + }, + "_final_publications": { + "count": 1, + "ms": 19.680165918543935 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.7149999514222145 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.9695420050993562 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 16.130291973240674 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 4.977707983925939 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 18.96450005006045 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 2.739582909271121 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 6.958708050660789 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.9093750743195415 + }, + "4860037db3700175": { + "count": 1, + "ms": 32.64320897869766 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 1.2667080154642463 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 0.49120804760605097 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 2.533750026486814 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 16.540458076633513 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 93.54887495283037, + "thread_submit_ms": 93.56145793572068, + "worker_enter_ms": 94.34149996377528, + "worker_exit_ms": 297.8986669331789, + "await_resume_ms": 300.5399169633165, + "adapter_return_ms": 306.80899997241795 + }, + "vespa_native_seconds": { + "querytime": 0.043000000000000003, + "summaryfetchtime": 0.051000000000000004, + "searchtime": 0.095 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 471.67395800352097 + }, + { + "sequence": 13, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 5.820124875754118 + }, + "dense": { + "count": 1, + "ms": 93.94433302804828 + }, + "sparse": { + "count": 1, + "ms": 1.1335000162944198 + }, + "prepare_query": { + "count": 1, + "ms": 95.12208309024572 + }, + "vespa": { + "count": 1, + "ms": 319.22808301169425 + }, + "visibility": { + "count": 1, + "ms": 18.21220899000764 + }, + "execute": { + "count": 1, + "ms": 339.0349169494584 + }, + "_enrich": { + "count": 1, + "ms": 22.3297500051558 + }, + "_coverage": { + "count": 1, + "ms": 14.834166970103979 + }, + "_final_publications": { + "count": 1, + "ms": 9.104582946747541 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 4.30383300408721 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.797541044652462 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 2.975999959744513 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 12.258249917067587 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 3.5086669959127903 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 10.332042002119124 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.1515000369399786 + }, + "2499200562431a3a": { + "count": 1, + "ms": 6.353541975840926 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 98.91566692385823, + "thread_submit_ms": 98.921624943614, + "worker_enter_ms": 99.02224992401898, + "worker_exit_ms": 414.5970839308575, + "await_resume_ms": 414.8382089333609, + "adapter_return_ms": 418.14120893832296 + }, + "vespa_native_seconds": { + "querytime": 0.045, + "summaryfetchtime": 0.116, + "searchtime": 0.162 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 486.68208392336965 + }, + { + "sequence": 15, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 2.754500019364059 + }, + "dense": { + "count": 1, + "ms": 93.76249997876585 + }, + "sparse": { + "count": 1, + "ms": 1.439583022147417 + }, + "prepare_query": { + "count": 1, + "ms": 95.2114169485867 + }, + "vespa": { + "count": 1, + "ms": 186.33945798501372 + }, + "visibility": { + "count": 1, + "ms": 55.72645796928555 + }, + "execute": { + "count": 1, + "ms": 243.3704580180347 + }, + "_enrich": { + "count": 1, + "ms": 127.53029202576727 + }, + "_coverage": { + "count": 1, + "ms": 10.312499944120646 + }, + "_final_publications": { + "count": 1, + "ms": 7.153499987907708 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.6069590346887708 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 22.428249940276146 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 25.53637500386685 + }, + "227345276c4b5446": { + "count": 1, + "ms": 117.5829169806093 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.810332945547998 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 2.4559160228818655 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 4.415082978084683 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 2.6487080613151193 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 97.31341700535268, + "thread_submit_ms": 97.31720900163054, + "worker_enter_ms": 98.00841705873609, + "worker_exit_ms": 279.1433749953285, + "await_resume_ms": 279.5155419735238, + "adapter_return_ms": 283.6489169858396 + }, + "vespa_native_seconds": { + "querytime": 0.039, + "summaryfetchtime": 0.043000000000000003, + "searchtime": 0.084 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 486.83012498077005 + }, + { + "sequence": 20, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.2466669799759984 + }, + "dense": { + "count": 1, + "ms": 125.17383298836648 + }, + "sparse": { + "count": 1, + "ms": 3.4318750258535147 + }, + "prepare_query": { + "count": 1, + "ms": 128.62733309157193 + }, + "vespa": { + "count": 1, + "ms": 146.52225002646446 + }, + "visibility": { + "count": 1, + "ms": 21.02175005711615 + }, + "execute": { + "count": 1, + "ms": 168.73904201202095 + }, + "_enrich": { + "count": 1, + "ms": 126.72604201361537 + }, + "_coverage": { + "count": 1, + "ms": 12.922166963107884 + }, + "_final_publications": { + "count": 1, + "ms": 9.215540951117873 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.2911669220775366 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 0.4805000498890877 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 3.204040927812457 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 5.898417090065777 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 115.89466710574925 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 2.005874994210899 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 2.3886249400675297 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 7.568750064820051 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 6.003041984513402 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 131.62004202604294, + "thread_submit_ms": 131.6234590485692, + "worker_enter_ms": 131.65604206733406, + "worker_exit_ms": 274.65837507043034, + "await_resume_ms": 274.9237919924781, + "adapter_return_ms": 278.13983405940235 + }, + "vespa_native_seconds": { + "querytime": 0.034, + "summaryfetchtime": 0.023, + "searchtime": 0.058 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 449.8126250691712 + }, + { + "sequence": 18, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.7867088578641415 + }, + "dense": { + "count": 1, + "ms": 120.1005419716239 + }, + "sparse": { + "count": 1, + "ms": 4.211790976114571 + }, + "prepare_query": { + "count": 1, + "ms": 124.37412491999567 + }, + "vespa": { + "count": 1, + "ms": 257.70354201085865 + }, + "visibility": { + "count": 1, + "ms": 28.95637508481741 + }, + "execute": { + "count": 1, + "ms": 288.38570800144225 + }, + "_enrich": { + "count": 1, + "ms": 22.36049994826317 + }, + "_coverage": { + "count": 1, + "ms": 16.854791902005672 + }, + "_final_publications": { + "count": 1, + "ms": 7.475791964679956 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.691541962325573 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.6582499956712127 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 12.282041017897427 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 2.7418748941272497 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 12.086707982234657 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 0.8839999791234732 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 5.212290911003947 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.2736670430749655 + }, + "4860037db3700175": { + "count": 1, + "ms": 0.16220798715949059 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.21908397320657969 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 0.11995900422334671 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 2.5255829095840454 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 3.300667041912675 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 128.8688329514116, + "thread_submit_ms": 128.89004196040332, + "worker_enter_ms": 128.95845796447247, + "worker_exit_ms": 363.96941693965346, + "await_resume_ms": 374.1924579953775, + "adapter_return_ms": 386.5694999694824 + }, + "vespa_native_seconds": { + "querytime": 0.04, + "summaryfetchtime": 0.06, + "searchtime": 0.10200000000000001 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 464.9904170073569 + }, + { + "sequence": 19, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 3.1694999197497964 + }, + "dense": { + "count": 1, + "ms": 112.03283397480845 + }, + "sparse": { + "count": 1, + "ms": 7.914333022199571 + }, + "prepare_query": { + "count": 1, + "ms": 120.17295800615102 + }, + "vespa": { + "count": 1, + "ms": 219.4754589581862 + }, + "visibility": { + "count": 1, + "ms": 53.484917036257684 + }, + "execute": { + "count": 1, + "ms": 275.14170797076076 + }, + "_enrich": { + "count": 1, + "ms": 78.62829195801169 + }, + "_coverage": { + "count": 1, + "ms": 5.563125014305115 + }, + "_final_publications": { + "count": 1, + "ms": 9.205333073623478 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.083083032630384 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 9.56091599073261 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 23.958457983098924 + }, + "227345276c4b5446": { + "count": 1, + "ms": 67.18949996866286 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 0.3800831036642194 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 0.8849999867379665 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.18833298236131668 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 5.697582964785397 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 123.94775007851422, + "thread_submit_ms": 123.97029204294086, + "worker_enter_ms": 124.3567920755595, + "worker_exit_ms": 318.38562502525747, + "await_resume_ms": 319.638334098272, + "adapter_return_ms": 343.42062508221716 + }, + "vespa_native_seconds": { + "querytime": 0.037, + "summaryfetchtime": 0.06, + "searchtime": 0.098 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 492.43737501092255 + }, + { + "sequence": 17, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 2.748124999925494 + }, + "dense": { + "count": 1, + "ms": 128.40916693676263 + }, + "sparse": { + "count": 1, + "ms": 3.2955839997157454 + }, + "prepare_query": { + "count": 1, + "ms": 131.7740420345217 + }, + "vespa": { + "count": 1, + "ms": 362.80562495812774 + }, + "visibility": { + "count": 1, + "ms": 13.968167011626065 + }, + "execute": { + "count": 1, + "ms": 377.57695792242885 + }, + "_enrich": { + "count": 1, + "ms": 12.1610420756042 + }, + "_coverage": { + "count": 1, + "ms": 5.292040994390845 + }, + "_final_publications": { + "count": 1, + "ms": 4.601667053066194 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 1.487292000092566 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 1.5190830454230309 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 7.20541609916836 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 4.393625073134899 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 0.8197919232770801 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 2.3606669856235385 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.1662500435486436 + }, + "2499200562431a3a": { + "count": 1, + "ms": 1.6536659095436335 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 134.5634589670226, + "thread_submit_ms": 134.57520899828523, + "worker_enter_ms": 135.32129197847098, + "worker_exit_ms": 494.1168340155855, + "await_resume_ms": 495.0119169661775, + "adapter_return_ms": 497.36733397003263 + }, + "vespa_native_seconds": { + "querytime": 0.044, + "summaryfetchtime": 0.126, + "searchtime": 0.17200000000000001 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 534.515458974056 + }, + { + "sequence": 23, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 6.207042024470866 + }, + "dense": { + "count": 1, + "ms": 251.6138330101967 + }, + "sparse": { + "count": 1, + "ms": 5.3990420419722795 + }, + "prepare_query": { + "count": 1, + "ms": 257.07570801023394 + }, + "vespa": { + "count": 1, + "ms": 282.42316702380776 + }, + "visibility": { + "count": 1, + "ms": 67.78208294417709 + }, + "execute": { + "count": 1, + "ms": 351.3190840603784 + }, + "_enrich": { + "count": 1, + "ms": 29.396165977232158 + }, + "_coverage": { + "count": 1, + "ms": 61.066332971677184 + }, + "_final_publications": { + "count": 1, + "ms": 88.22549995966256 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 2.3950840113684535 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 13.396708061918616 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 21.271208999678493 + }, + "06f6532ebc76de6e": { + "count": 1, + "ms": 14.94274998549372 + }, + "e88a191d2772b4c3": { + "count": 1, + "ms": 17.872833996079862 + }, + "7ff2c92fa74aac62": { + "count": 1, + "ms": 2.6501669781282544 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 46.39487492386252 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.1212920071557164 + }, + "5ba1d0d81b5d4a1a": { + "count": 1, + "ms": 74.2000830359757 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 259.72420803736895, + "thread_submit_ms": 259.73125000018626, + "worker_enter_ms": 259.93562501389533, + "worker_exit_ms": 533.6556250695139, + "await_resume_ms": 533.8690420612693, + "adapter_return_ms": 542.1450000721961 + }, + "vespa_native_seconds": { + "querytime": 0.055, + "summaryfetchtime": 0.044, + "searchtime": 0.101 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 794.0028749871999 + }, + { + "sequence": 24, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 10.192250949330628 + }, + "dense": { + "count": 1, + "ms": 243.9227090217173 + }, + "sparse": { + "count": 1, + "ms": 5.815709009766579 + }, + "prepare_query": { + "count": 1, + "ms": 249.76879195310175 + }, + "vespa": { + "count": 1, + "ms": 348.05883292574435 + }, + "visibility": { + "count": 1, + "ms": 49.388541956432164 + }, + "execute": { + "count": 1, + "ms": 399.75908293854445 + }, + "_enrich": { + "count": 1, + "ms": 165.58324999641627 + }, + "_coverage": { + "count": 1, + "ms": 31.614792067557573 + }, + "_final_publications": { + "count": 1, + "ms": 12.574583990499377 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 9.072582935914397 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 11.464042006991804 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 18.014999921433628 + }, + "4ee445d5d52cef6a": { + "count": 1, + "ms": 3.193582990206778 + }, + "d0899a4cbc066a50": { + "count": 1, + "ms": 128.07166704442352 + }, + "d5b9cf8005c6b2d3": { + "count": 1, + "ms": 1.9443329656496644 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 7.557749981060624 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.7783749606460333 + }, + "4860037db3700175": { + "count": 1, + "ms": 2.628499991260469 + }, + "0e5689f21b6e2cb6": { + "count": 1, + "ms": 0.7797080324962735 + }, + "c86dd043af29ddfe": { + "count": 1, + "ms": 0.3227499546483159 + }, + "4759652f7e45b471": { + "count": 2, + "ms": 2.2662499686703086 + }, + "bc2564b15d26cfb8": { + "count": 1, + "ms": 9.172708028927445 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 260.2858330355957, + "thread_submit_ms": 260.3041250258684, + "worker_enter_ms": 260.57608297560364, + "worker_exit_ms": 603.818624978885, + "await_resume_ms": 604.1352079482749, + "adapter_return_ms": 608.341958024539 + }, + "vespa_native_seconds": { + "querytime": 0.064, + "summaryfetchtime": 0.075, + "searchtime": 0.14100000000000001 + }, + "vespa_status": 200, + "candidates": 200, + "total_ms": 870.1690000016242 + }, + { + "sequence": 22, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 4.9316249787807465 + }, + "dense": { + "count": 1, + "ms": 252.40749993827194 + }, + "sparse": { + "count": 1, + "ms": 5.0570000894367695 + }, + "prepare_query": { + "count": 1, + "ms": 257.47762504033744 + }, + "vespa": { + "count": 1, + "ms": 533.1936249276623 + }, + "visibility": { + "count": 1, + "ms": 33.718875027261674 + }, + "execute": { + "count": 1, + "ms": 569.8454580269754 + }, + "_enrich": { + "count": 1, + "ms": 38.22412504814565 + }, + "_coverage": { + "count": 1, + "ms": 12.29758292902261 + }, + "_final_publications": { + "count": 1, + "ms": 9.380374918691814 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 3.5796259762719274 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 4.136416013352573 + }, + "6948fc1e998d8c91": { + "count": 1, + "ms": 23.413957911543548 + }, + "2a3e378facdff592": { + "count": 1, + "ms": 28.427459066733718 + }, + "024351fdb57a17bd": { + "count": 1, + "ms": 1.3487499672919512 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 8.956332923844457 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 0.35566696897149086 + }, + "2499200562431a3a": { + "count": 1, + "ms": 5.193708930164576 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 260.61008300166577, + "thread_submit_ms": 260.61320793814957, + "worker_enter_ms": 261.1957909539342, + "worker_exit_ms": 781.5220409538597, + "await_resume_ms": 789.4895409699529, + "adapter_return_ms": 793.8009579665959 + }, + "vespa_native_seconds": { + "querytime": 0.067, + "summaryfetchtime": 0.14100000000000001, + "searchtime": 0.21 + }, + "vespa_status": 200, + "candidates": 198, + "total_ms": 892.7794579649344 + }, + { + "sequence": 21, + "stages": { + "_resolve_scopes": { + "count": 2, + "ms": 10.100001120008528 + }, + "dense": { + "count": 1, + "ms": 248.2455000281334 + }, + "sparse": { + "count": 1, + "ms": 4.537333967164159 + }, + "prepare_query": { + "count": 1, + "ms": 253.28354199882597 + }, + "vespa": { + "count": 1, + "ms": 332.133625051938 + }, + "visibility": { + "count": 1, + "ms": 27.713124989531934 + }, + "execute": { + "count": 1, + "ms": 362.4508329667151 + }, + "_enrich": { + "count": 1, + "ms": 257.4409160297364 + }, + "_coverage": { + "count": 1, + "ms": 6.178250070661306 + }, + "_final_publications": { + "count": 1, + "ms": 9.463750058785081 + } + }, + "sql": { + "3748346667f87dba": { + "count": 2, + "ms": 7.278832956217229 + }, + "8b73a5bccd0cd7ff": { + "count": 1, + "ms": 5.297499941661954 + }, + "456d4afd8fa45e37": { + "count": 1, + "ms": 14.771750080399215 + }, + "227345276c4b5446": { + "count": 1, + "ms": 243.41858306434005 + }, + "f3f9cbf3fee22c9f": { + "count": 1, + "ms": 7.21791700925678 + }, + "8afce4da4e764b48": { + "count": 1, + "ms": 1.972291967831552 + }, + "bea4ae99e32371a1": { + "count": 1, + "ms": 1.4450419694185257 + }, + "a0f7d10209698549": { + "count": 1, + "ms": 4.8278330359607935 + } + }, + "errors": [], + "vespa_timeline": { + "adapter_enter_ms": 260.712374933064, + "thread_submit_ms": 260.8172500040382, + "worker_enter_ms": 261.0166249796748, + "worker_exit_ms": 582.3783749947324, + "await_resume_ms": 582.7239169739187, + "adapter_return_ms": 592.8405419690534 + }, + "vespa_native_seconds": { + "querytime": 0.062, + "summaryfetchtime": 0.059000000000000004, + "searchtime": 0.122 + }, + "vespa_status": 200, + "candidates": 196, + "total_ms": 899.8248749412596 + } + ] +} diff --git a/evaluation/README.md b/evaluation/README.md new file mode 100644 index 0000000000..ed61fad63a --- /dev/null +++ b/evaluation/README.md @@ -0,0 +1,313 @@ +# Retrieval evaluation + +Offline evaluation of the **delivered record ranking**, with separately observed +latency samples. This does not run sync, query a provider, or change an index. +The production service does not import this package. `uv` installs the pinned +evaluation dependencies separately; no additional production infrastructure. + +```sh +uv run evaluation/retrieval.py /private/path/dataset.json +# Prints the dataset fingerprint. Record it in the corresponding run file. +uv run evaluation/retrieval.py /private/path/dataset.json \ + --run /private/path/run.json --output /private/path/report.json + +uv run --no-project --with ir-measures==0.4.3 --with pydantic==2.11.9 \ + --with pytest python -m pytest evaluation/tests -q --confcutdir=evaluation +``` + +Reports are created with mode 0600 and refuse to overwrite files. Keep real +queries, judgments, record IDs and response captures out of Git. Reports omit +query text but can still reveal sensitive labels through query IDs and tags. +Validation errors can echo input: do not publish raw error logs from private runs. + +## Dataset + +```json +{ + "corpus_id": "frozen-corpus-manifest-sha256", + "version": "judgments-v1", + "queries": [{ + "id": "meeting-hindi", + "text": "परियोजना बैठक कब है?", + "tags": ["language:hi", "intent:known-item", "split:holdout"], + "expectation": "relevant_records", + "expectation_assessor": "human", + "judgments": [ + {"record_id": "calendar:meeting-1", "relevance": 3, "assessor": "human"}, + {"record_id": "gmail:promotion-2", "relevance": 0, "assessor": "human"} + ] + }] +} +``` + +Use stable record identities, not chunk IDs. The corpus manifest should pin the +record revisions, permissions/scopes, extraction and index versions. Its content +is the operator's responsibility: this offline scorer verifies the supplied +corpus ID and exact dataset fingerprint, **not the live index's contents**. +Include applied account/date/type filters in the frozen experiment specification; +do not compare runs using different filters as ranking improvements. + +Grades: 0 irrelevant, 1 marginally useful, 2 useful, 3 directly satisfies intent. +Binary precision, recall and reciprocal rank count grades >= 1 as relevant. +`ir-measures` supplies nDCG with its default linear graded gain, RR, P and R; +we do not maintain custom implementations of that scoring math. + +Every known-answer query needs a positive judgment. `no_answer` queries explicitly +assert no relevant answer in this corpus and have no positive judgments. Their +empty-success outcome is reported separately, not averaged into nDCG/recall. +An error returning nothing is not a correct abstention. No-answer labels need +evidence; merely failing to find an item does not establish absence. + +### Duplicate crowding + +Distinct record IDs may contain redundant answers. A session trial returned19 +identical messages in20 keyword slots; ordinary record-level relevance metrics +can reward every copy. Optionally label redundant originals per query: + +```json +"duplicate_groups": [ + {"record_ids": ["session:message-a", "session:message-b"], "assessor": "human"} +] +``` + +Members must be distinct, disjoint across groups, and have explicit equal +relevance judgments. Judge redundancy for the query's intent and filters: +identical text at different dates need not answer a date-specific query equally. +Sharing a session, mailbox or channel alone does not establish redundancy. +Agent-generated labels must use `assessor: "agent"`, even when computed from +exact original text. Keep authoritative message identities intact. + +The per-query `duplicates` report counts labeled records, extra slots after the +first member of each group, and unassigned records in the delivered top20. +Unassigned records are not claimed unique. Without duplicate labels the report +is null, not zero. Counts are descriptive; a failed/empty request is not proof of +good diversity. Existing nDCG/MRR/precision/recall scores use the unchanged ranking; +the evaluator never silently collapses results to improve scores. + +Duplicate labels participate in the dataset fingerprint and assessor provenance. +This schema revision also changes fingerprints for previously parsed datasets; +regenerate the fingerprint and bind runs to the validated dataset deliberately. +Do not copy a hash from a different set of labels to make a comparison pass. + +## Run + +```json +{ + "system": "commit+embedding+retrieval+reranker+configuration", + "dataset_sha256": "", + "corpus_id": "frozen-corpus-manifest-sha256", + "results": [{ + "query_id": "meeting-hindi", + "status": "success", + "record_ids": ["gmail:promotion-2", "calendar:meeting-1"] + }], + "timings": [{ + "request_id": "trial-001", + "query_id": "meeting-hindi", + "phase": "end_to_end", + "condition": "unknown", + "status": "success", + "duration_ms": 812.4 + }] +} +``` + +The array order is authoritative. Unique descending synthetic scores preserve it +when calling the metrics library; scores from different retrieval engines are +not compared. Duplicate record IDs are rejected rather than silently repaired. +Include every query exactly once, recording errors/timeouts with empty results. +Partial responses keep their returned ranking and an explicit `partial` status. +For repeated ranking trials, save separate runs instead of selecting the best. + +Latency may have repeated samples per query, with unique request IDs. Supported +phases: end_to_end, retrieval, reranking, read. Measure with a monotonic clock; +record the actual condition as cold/warm/unknown. First request does not prove +cold caches. Timing groups preserve phase, cache condition and outcome, including +errors/timeouts. No timings are inferred from relevance results. End-to-end HTTP +latency does not establish browser paint latency. Percentiles use nearest rank; +sample count and maximum accompany p50/p95. Do not draw tail conclusions from a +handful of measurements or mix concurrency/load conditions in one run. + +## What constitutes improvement + +Compare the same frozen corpus, scopes, labels and held-out queries. Pool records +from candidate systems and judge previously unjudged records before concluding +one wins. Reports expose per-query top-10 judged/unjudged counts and assessor +provenance. Missing judgments receive no relevance credit; they are not relabeled +irrelevant. Recall means recall of **judged relevant records**, not account-wide +recall. Failed answerable queries contribute zero instead of disappearing from +the denominator. Inspect per-query and tagged regressions alongside aggregates. + +Real-corpus collection, request timing instrumentation, reviewed judgments, +snippet evaluation and corpus-freezing automation are still separate work. +The focused synthetic tests verify evaluator behavior, not search quality. + +Reference: [ir-measures interfaces and formats](https://ir-measur.es/en/latest/getting-started.html). + +## Bounded native publishing helper + +`evaluation.native_import.publish_native` publishes already-staged `NativeSnapshot` +values through the authenticated native HTTP API; it neither discovers nor fetches +source content. Run with the backend environment and `PYTHONPATH=backend:.` from the +repository root. The injected `httpx.AsyncClient` base URL must target the API root +with a trailing slash. Retain the exact input and request key after a transport error: +reinvoking resumes the durable cursor; changed input with that key conflicts. The +returned terminal summary certifies bounded capture, not search publication. + +Tests live in the backend native-ingestion suite and require disposable PostgreSQL. +The standalone retrieval-metrics test environment does not import this helper. + +### Operator command + +From the repository root, using the backend Python environment: + +```sh +PYTHONPATH=backend:. backend/.venv/bin/python -m evaluation.native_import_cli \ + --url http://127.0.0.1:18086/api/v1/ \ + --input staged-knowledge.json --owner OWNER_ID --dataset knowledge \ + --collection COLLECTION_ID --request-key stable-import-key +``` + +Supply `AIRWEAVE_API_KEY` through the process environment (for example a secret +manager); there is no key argument or dotenv loading. No server database/settings +credentials are needed. Use the backend API key authorized to attest this owner. +The file is a UTF-8 JSON array of `NativeSnapshot` values, at most **32 MiB**. +Use `--dataset sessions` for staged session roots and their original messages. +This command publishes the supplied bounded set, not an account-wide backfill; +it does not infer deletions from records omitted from the file. + +External URLs must use HTTPS; HTTP is allowed only for loopback IPs or localhost. +URL credentials, queries and fragments are rejected. Redirects and environment +proxies are disabled. Each HTTP operation has a 10-second connect timeout and +60-second read/write/pool timeout, not a whole-import deadline. No automatic +retry occurs. Keep the exact staged file and request key on failure; repeat the +same command to recover durable progress. Do not change the file under that key. + +Success writes one JSON capture summary to stdout, including +`capture_complete: true`, `coverage: "bounded"`, and `indexing: "not_verified"`. +Search publication must be verified separately. Errors use stderr without raw +payloads, keys, file paths or destination URLs. Exit codes: 0 completed capture; +2 invalid configuration/input; 3 unknown network outcome; 4 destination rejection +or inconsistent import progress (including cross-record preflight rejection); +130 interrupted; 1 unexpected failure. Failed commands may have committed earlier +pages: a nonzero exit is not evidence of rollback. + +## Grouped conversation results + +Judge the unit the user actually sees. A conversation card can be relevant even +when its displayed message is not the answer. Keep these two experiments separate: + +- **Card ranking:** stable conversation identity for a session/thread card, and + original record identity for an ungrouped result. Judge the conversation as a + whole. Use a separately fingerprinted dataset tagged `unit:card`. +- **Displayed-original ranking:** representative original followed by the explicitly + returned additional matches, preserving card order and within-card order. Judge + those exact original identities in a separate dataset tagged `unit:displayed_original`. + Do not add unreturned members merely because they belong to a returned group. + This is a ranking of inspectable originals, not the number of visible cards. + +Both use the existing evaluator; no second scoring implementation is needed. +A card hit does not prove passage recall, and `matched_records` is not a list of +answers or evidence that every member is relevant. Keep query filters, corpus +revisions, model settings and assessment provenance fixed for both experiments. +The one-session trial in the product worklog is diagnostic, not a judged benchmark: +it found the session in every mode, but the expected passage only in keyword and +hybrid mode. More diverse sessions and independently judged queries are required. + +`evaluation.owned_retrieval.delivered_result` exports these two units from the +existing typed `OwnedSearchResponse`. Supply a frozen tuple of `CorpusRecord` +locators with destination record/sync/revision, preserved provider identity, and +a stable original source ID (account or attested owner/dataset). The adapter +checks each returned original against the census and hashes source plus native +identity for rebuild-stable evaluation IDs. Conversation IDs additionally bind +group kind and native ID; accounts with the same native thread ID stay separate. +Attest optional `ConversationIdentity` from canonical parent identity or a +validated retained email thread before retrieval. A returned group or additional +member contradicting that frozen membership is rejected. Use `CorpusRecord.card_id` +for card judgments, independent of which member becomes the representative. +Use `unit="card"` for primary UI/API ranking and `unit="displayed_original"` +for the explicitly flattened diagnostic. Returned match counts never invent +hidden original members. An incomplete response retains its ranking and is +marked partial. This helper requires the backend environment; it does not query +the service or verify publication by itself. + +`evaluation.retrieval.pool_unjudged(dataset, runs)` returns each query's sorted +union of delivered IDs without existing judgments. It checks the exact dataset +and corpus binding for every run. Assess that private pool before comparing +metrics; membership in the pool is neither a positive nor a negative label. +If labels change, deliberately regenerate the dataset fingerprint and rebind +the preserved observations; never rerun queries selectively to improve scores. + +The delivery adapter's synthetic tests use backend fixtures: + +```sh +PYTHONPATH=backend:. backend/.venv/bin/python -m pytest \ + backend/tests/unit/search/test_owned_evaluation.py -q +``` + +## HTTP replay of a retained corpus + +The replay command runs one mode against an **already qualified, retained** corpus. +It does not copy records, publish, fetch providers or rebuild an index. The earlier +mixed-corpus trial was cleaned; its census cannot be replayed against missing records. +Prepare and qualify a retained fixture separately before using this command. + +```sh +PYTHONPATH=backend:. backend/.venv/bin/python -m evaluation.replay_cli \ + --url http://127.0.0.1:18086/api/v1/ \ + --dataset /private/path/card-dataset.json \ + --census /private/path/qualified-corpus.json \ + --output /private/path/new-hybrid-run \ + --system 'reviewed-commit+embedding+retrieval+reranker+configuration' \ + --mode hybrid --unit card --limit 20 +``` + +Authentication uses `AIRWEAVE_API_KEY` and the census organization via +`X-Organization-ID`. The native publisher's URL rules also apply here: external +HTTPS, loopback HTTP, no URL credentials, no redirects or environment proxies. +No server database credentials or dotenv files are needed; `--help` needs no key. +Each HTTP operation has a 10-second connect and 60-second read/write/pool timeout. +Requests run once, sequentially, without retries or a whole-run deadline. + +Inputs are strict `Dataset` and `evaluation.replay.FrozenCorpus` JSON, each at most +32 MiB. The census contains `corpus_id`, `organization_id`, explicit `sync_ids` +(at most 20) and `records`. Each `FrozenRecord` extends `CorpusRecord` with the +qualified `capture_hash`, `indexed_pipeline_version`, exact `extraction` parts, +and canonical `parent` identity. Preserve source revisions/native versions in the +qualified corpus specification identified by `corpus_id`; destination revisions +are separately checked here. Freeze conversation membership before retrieval. +The census must enumerate **every active, content-available record in each selected +sync**, including intentionally excluded records; it need not describe other +sources in the provider account. Empty selected syncs are allowed. Every query +must carry exactly the matching `unit:card` or `unit:displayed_original` tag and +every judgment must refer to an identity in this corpus. + +Before and after querying, the runner paginates the existing record-list endpoint +and checks exact eligible membership, native/parent identities, destination +revisions, capture hashes and current index/pipeline revisions. Missing, extra, +withdrawn or changed records reject the comparison. Every explicitly returned +representative and additional match must also have exactly the qualified +extraction coverage. No per-record fetches are added. Availability is API +metadata/permission evidence, **not physical blob integrity**. These live census +traversals are non-atomic; changes that occur and revert between observations can +escape detection. The API does not attest the supplied model/system descriptor. + +The fresh output directory is mode 0700; files are 0600 and never overwritten. +It retains validated inputs, paginated census responses, numbered search request +and raw response files, every query outcome, mode/fallback metadata and HTTP +timings with unknown cache state. Response bytes are saved before decoding. +HTTP failures, timeouts and malformed responses remain failed query outcomes; +corpus or delivered-proof contradictions abort scoring while preserving evidence. +Only successful before/after checks produce `run.json` and an unjudged candidate +pool. Query failures still stay in the metric denominator. No labels are invented. + +Use `--report` to invoke the existing offline scorer; install its pinned +`ir-measures==0.4.3` dependency in that environment first. Alternatively, score +the retained `dataset.json` and `run.json` with the standalone command above. +Exit codes: 0 replay retained (inspect failed/partial counts); 2 invalid inputs or +output; 3 failed census HTTP verification; 4 corpus/proof mismatch; 5 unavailable +offline scoring dependency; 130 interrupted; 1 unexpected failure. + +`make test-search` runs both the delivery adapter and replay boundary tests. +These synthetic HTTP tests establish runner behavior, not live ranking quality +or qualification of any existing local corpus. diff --git a/evaluation/__init__.py b/evaluation/__init__.py new file mode 100644 index 0000000000..a2cce858a4 --- /dev/null +++ b/evaluation/__init__.py @@ -0,0 +1 @@ +"""Offline evaluation tools; not imported by the service runtime.""" diff --git a/evaluation/native_import.py b/evaluation/native_import.py new file mode 100644 index 0000000000..1ed985288d --- /dev/null +++ b/evaluation/native_import.py @@ -0,0 +1,311 @@ +"""Bounded staged-native publisher; retry by retaining the same input and request key.""" + +import hashlib +import json +from typing import Literal +from urllib.parse import quote +from uuid import UUID, uuid5 + +import httpx +from pydantic import Field, ValidationError + +from airweave.domains.entities.canonical.requests import CompletedScope +from airweave.domains.native_ingestion.import_models import ( + NativeImportState, + StartNativeImport, +) +from airweave.domains.native_ingestion.models import NativeModel, NativeSnapshot +from airweave.domains.native_ingestion.page_models import ( + CommitNativePage, + NativePageAck, +) +from airweave.domains.native_ingestion.scope_models import ( + BeginNativeScope, + NativeScopeState, + ReconcileNativeScope, +) +from airweave.domains.native_ingestion.source_models import ( + EnsureNativeSource, + NativeSource, +) + +_PAGE_NAMESPACE = UUID("3c2ba962-a612-4ca3-a29b-342521fd35bc") +_PAGE_SIZE = 500 + + +class NativePublishError(ValueError): + """Publication cannot safely resume; no original content appears in diagnostics.""" + + +class _Cursor(NativeModel): + snapshot_id: str + next_offset: int = Field(ge=0) + + +class _Scope(NativeModel): + scope: CompletedScope + snapshots: tuple[NativeSnapshot, ...] + + +def _prepare( + owner: str, + dataset: Literal["knowledge", "sessions"], + snapshots: tuple[NativeSnapshot, ...], +) -> tuple[_Scope, ...]: + # Validate copied/constructed models again before any server mutation. + checked = tuple( + NativeSnapshot.model_validate(item.model_dump(mode="json")) + for item in snapshots + ) + unique = set() + for item in checked: + key = (item.identity.record_type, item.identity.entity_key) + if key in unique or item.owner_id != owner or item.operation != "upsert": + raise NativePublishError( + "Staged native identity, owner or operation is invalid" + ) + unique.add(key) + allowed = ("knowledge",) if dataset == "knowledge" else ("session", "message") + if item.identity.record_type not in allowed: + raise NativePublishError("Staged native dataset is mixed") + kind = "knowledge" if dataset == "knowledge" else "session" + roots = tuple( + sorted( + (item for item in checked if item.identity.record_type == kind), + key=lambda item: item.identity.native_id, + ) + ) + scopes = [_Scope(scope=CompletedScope(record_type=kind), snapshots=roots)] + if dataset == "sessions": + parents = {item.identity.native_id: item for item in roots} + children: dict[str, list[NativeSnapshot]] = {key: [] for key in parents} + for item in checked: + if item.identity.record_type != "message": + continue + parent = parents.get(item.identity.container_id) + if ( + parent is None + or item.parent != parent.identity + or item.version != parent.version + ): + raise NativePublishError( + "Staged message has no matching session/version" + ) + children[parent.identity.native_id].append(item) + for parent in roots: + scopes.append( + _Scope( + scope=CompletedScope( + record_type="message", + container_id=parent.identity.native_id, + parent=parent.identity, + ), + snapshots=tuple( + sorted( + children[parent.identity.native_id], + key=lambda item: item.identity.native_id, + ) + ), + ) + ) + return tuple(scopes) + + +async def _call[T: NativeModel]( + client: httpx.AsyncClient, + method: str, + path: str, + model: type[T], + body: NativeModel | None = None, +) -> T: + response = await client.request( + method, path, json=body.model_dump(mode="json") if body else None + ) + if response.status_code != 200: + raise NativePublishError( + f"Native destination rejected {method} with HTTP {response.status_code}" + ) + try: + return model.model_validate_json(response.content) + except ValidationError: + raise NativePublishError( + "Native destination returned malformed progress" + ) from None + + +def _attest_import( + state: NativeImportState, source: NativeSource, key: str, digest: str +) -> None: + if ( + state.source_id != source.source_connection_id + or state.request_key != key + or state.request.snapshot_id != digest + or state.request.coverage != "bounded" + ): + raise NativePublishError("Native import response differs from staged intent") + if state.status == "completed" and ( + state.summary is None + or not state.summary.capture_complete + or state.summary.outcome != "completed" + or state.summary.coverage != "bounded" + ): + raise NativePublishError( + "Completed import has no valid bounded capture summary" + ) + + +def _attest_scope( + state: NativeScopeState, scope: _Scope, imported: NativeImportState +) -> None: + if ( + state.cycle_id != imported.cycle_id + or state.scope != scope.scope + or state.coverage != "bounded" + ): + raise NativePublishError("Native scope does not belong to this bounded import") + + +async def publish_native( + client: httpx.AsyncClient, + *, + owner_id: str, + dataset: Literal["knowledge", "sessions"], + collection: str, + request_key: str, + snapshots: tuple[NativeSnapshot, ...], +) -> NativeImportState: + """Publish already staged originals; transport failures require identical caller retry. + + The authenticated client base URL targets the destination API root. No source + reads, credentials, discovery, automatic retries or indexing claims are involved. + """ + ensure = EnsureNativeSource( + owner_id=owner_id, dataset=dataset, collection=collection + ) + if not 1 <= len(request_key) <= 128: + raise NativePublishError("Native request key must contain 1 to 128 characters") + scopes = _prepare(owner_id, dataset, snapshots) + digest = hashlib.sha256( + json.dumps( + { + "publisher_version": 1, + "page_size": _PAGE_SIZE, + "source": ensure.model_dump(mode="json"), + "scopes": [scope.model_dump(mode="json") for scope in scopes], + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode() + ).hexdigest() + source = await _call(client, "PUT", "native/sources", NativeSource, ensure) + if source.binding != ensure.binding() or source.collection != collection: + raise NativePublishError( + "Native destination source binding differs from staged input" + ) + base = f"native/sources/{source.source_connection_id}/imports/{quote(request_key, safe='')}" + started = await _call( + client, + "PUT", + base, + NativeImportState, + StartNativeImport(snapshot_id=digest, coverage="bounded"), + ) + _attest_import(started, source, request_key, digest) + if started.status == "completed": + return started + if started.status != "running": + raise NativePublishError("Native import is terminal or unavailable") + for scope in scopes: + state = await _call( + client, + "PUT", + base + "/scopes", + NativeScopeState, + BeginNativeScope(scope=scope.scope), + ) + _attest_scope(state, scope, started) + if not state.cursor: + if state.phase != "collecting" or state.last_page is not None: + raise NativePublishError( + "Native scope is missing its staged input cursor" + ) + offset = 0 + else: + try: + cursor = _Cursor.model_validate(state.cursor) + except ValidationError: + raise NativePublishError("Native scope cursor is malformed") from None + offset = cursor.next_offset + if cursor.snapshot_id != digest or offset > len(scope.snapshots): + raise NativePublishError( + "Native scope cursor differs from staged input" + ) + if state.phase != "collecting" and offset != len(scope.snapshots): + raise NativePublishError("Native scope ended before the staged input") + if state.phase == "collecting" and ( + offset == len(scope.snapshots) or offset % _PAGE_SIZE + ): + raise NativePublishError( + "Native scope cursor is not a deterministic page boundary" + ) + while state.phase == "collecting": + end = min(offset + _PAGE_SIZE, len(scope.snapshots)) + page_id = uuid5( + _PAGE_NAMESPACE, + json.dumps( + [digest, scope.scope.model_dump(mode="json"), offset], + sort_keys=True, + ), + ) + ack = await _call( + client, + "PUT", + base + "/pages", + NativePageAck, + CommitNativePage( + page_id=page_id, + scope=scope.scope, + expected=state.version, + snapshots=scope.snapshots[offset:end], + cursor=_Cursor(snapshot_id=digest, next_offset=end).model_dump( + mode="json" + ), + final=end == len(scope.snapshots), + ), + ) + if ( + ack.page_id != page_id + or ack.version.sweep_id != state.version.sweep_id + or ack.version.revision != state.version.revision + 1 + or ack.phase + != ("reconciling" if end == len(scope.snapshots) else "collecting") + ): + raise NativePublishError( + "Native destination acknowledged inconsistent page progress" + ) + state = state.model_copy( + update={"phase": ack.phase, "version": ack.version} + ) + offset = end + if state.phase == "reconciling": + state = await _call( + client, + "POST", + base + "/scopes/reconcile", + NativeScopeState, + ReconcileNativeScope(scope=scope.scope, expected=state.version), + ) + _attest_scope(state, scope, started) + if state.phase != "complete": + raise NativePublishError("Bounded native scope did not complete") + completed = await _call(client, "POST", base + "/complete", NativeImportState) + _attest_import(completed, source, request_key, digest) + if ( + completed.import_id != started.import_id + or completed.cycle_id != started.cycle_id + or completed.status != "completed" + ): + raise NativePublishError("Native destination did not complete this import") + return completed diff --git a/evaluation/native_import_cli.py b/evaluation/native_import_cli.py new file mode 100644 index 0000000000..fb6fc31216 --- /dev/null +++ b/evaluation/native_import_cli.py @@ -0,0 +1,183 @@ +"""Publish one bounded staged snapshot using the existing durable native importer.""" + +import argparse +import asyncio +import ipaddress +import sys +from pathlib import Path +from typing import Literal +from urllib.parse import urlsplit + +import httpx +from pydantic import ( + Field, + HttpUrl, + SecretStr, + TypeAdapter, + ValidationError, + field_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict + +from airweave.domains.native_ingestion.models import NativeModel, NativeSnapshot +from evaluation.native_import import NativePublishError, publish_native + +MAX_INPUT_BYTES = 32 * 1024 * 1024 +SNAPSHOTS = TypeAdapter(tuple[NativeSnapshot, ...]) + + +class Settings(BaseSettings): + """Read authentication only from the process environment, never dotenv files.""" + + model_config = SettingsConfigDict(env_prefix="AIRWEAVE_", extra="ignore") + api_key: SecretStr + + @field_validator("api_key") + @classmethod + def valid_key(cls, value: SecretStr) -> SecretStr: + """Require a nonempty printable header value without exposing it.""" + key = value.get_secret_value() + if not key or any(ord(char) < 33 or ord(char) > 126 for char in key): + raise ValueError("Invalid API key") + return value + + +class PublishArguments(NativeModel): + """CLI input boundary; all state and retry decisions belong to the publisher.""" + + url: str + input: Path + owner: str = Field(min_length=1) + dataset: Literal["knowledge", "sessions"] + collection: str = Field(min_length=1, max_length=255) + request_key: str = Field(min_length=1, max_length=128) + + @field_validator("url") + @classmethod + def destination_url(cls, value: str) -> str: + """Allow explicit TLS destinations and local HTTP evaluation only.""" + if any(char.isspace() or ord(char) < 32 for char in value): + raise ValueError("Invalid destination") + parsed = urlsplit(value) + # Reject even empty query/fragment delimiters and URL-carried credentials. + if any(mark in value for mark in ("?", "#")) or parsed.username is not None: + raise ValueError( + "Destination must not include credentials, query or fragment" + ) + url = TypeAdapter(HttpUrl).validate_python(value) + if url.username is not None or url.password is not None: + raise ValueError("Destination must not include credentials") + if url.scheme == "http": + hostname = parsed.hostname or "" + try: + loopback = ipaddress.ip_address(hostname).is_loopback + except ValueError: + loopback = hostname == "localhost" + if not loopback: + raise ValueError("HTTP requires a loopback destination") + return str(url).rstrip("/") + "/" + + +class Parser(argparse.ArgumentParser): + """Argument errors do not echo potentially sensitive caller input.""" + + def error(self, message: str) -> None: + """Print static usage guidance instead of the original parser diagnostic.""" + self.exit(2, "Invalid arguments. Use --help for the accepted options.\n") + + +def parse_args(argv: list[str] | None) -> PublishArguments: + """Parse explicit publisher options into their validated boundary.""" + parser = Parser(description=__doc__) + parser.add_argument( + "--url", required=True, help="API root, e.g. https://host/api/v1/" + ) + parser.add_argument( + "--input", + required=True, + help="JSON array of NativeSnapshot values (max 32 MiB)", + ) + parser.add_argument("--owner", required=True) + parser.add_argument("--dataset", required=True, choices=("knowledge", "sessions")) + parser.add_argument("--collection", required=True) + parser.add_argument( + "--request-key", + required=True, + help="Retain this key and exact input for retries", + ) + return PublishArguments.model_validate(vars(parser.parse_args(argv))) + + +async def publish(arguments: PublishArguments, settings: Settings) -> str: + """Read bounded originals then delegate the entire workflow to the publisher.""" + with arguments.input.open("rb") as staged: + data = staged.read(MAX_INPUT_BYTES + 1) + if len(data) > MAX_INPUT_BYTES: + raise ValueError("Staged input exceeds the size limit") + snapshots = SNAPSHOTS.validate_json(data) + async with httpx.AsyncClient( + base_url=arguments.url, + headers={"X-API-Key": settings.api_key.get_secret_value()}, + follow_redirects=False, + trust_env=False, + timeout=httpx.Timeout(60, connect=10), + ) as client: + state = await publish_native( + client, + owner_id=arguments.owner, + dataset=arguments.dataset, + collection=arguments.collection, + request_key=arguments.request_key, + snapshots=snapshots, + ) + # The publisher attests this summary; never turn absent progress into success. + if state.summary is None: + raise NativePublishError("Destination returned no capture summary") + return state.summary.model_dump_json() + + +def main(argv: list[str] | None = None) -> int: + """Emit only the capture summary or a sanitized diagnostic with a stable exit code.""" + try: + arguments = parse_args(argv) + settings = Settings() + result = asyncio.run(publish(arguments, settings)) + except NativePublishError: + print( + "Publication rejected or progress invalid. Retain input and request key; " + "inspect destination state.", + file=sys.stderr, + ) + return 4 + except httpx.HTTPError: + print( + "Network outcome unknown. Retry only with the identical staged input and request key.", + file=sys.stderr, + ) + return 3 + except (ValidationError, ValueError, OSError): + print( + "Invalid configuration or staged input. " + "Check credentials, destination and JSON (maximum 32 MiB).", + file=sys.stderr, + ) + return 2 + except KeyboardInterrupt: + print( + "Interrupted; outcome may be unknown. Retain the exact staged input and request key.", + file=sys.stderr, + ) + return 130 + except Exception: + print( + "Unexpected failure; outcome may be unknown. " + "Retain the exact staged input and request key.", + file=sys.stderr, + ) + return 1 + print(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evaluation/owned_retrieval.py b/evaluation/owned_retrieval.py new file mode 100644 index 0000000000..a6ab89a53e --- /dev/null +++ b/evaluation/owned_retrieval.py @@ -0,0 +1,130 @@ +"""Export existing owned-search responses for the offline relevance evaluator. + +Requires the backend environment. Source identities come from the frozen corpus +census; destination UUIDs are locators, never cross-rebuild evaluation identities. +""" + +import hashlib +import json +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.search.owned_models import OwnedSearchMatch, OwnedSearchResponse +from evaluation.retrieval import Identifier, Result, Value + + +def _identifier(kind: str, values: object) -> str: + encoded = json.dumps( + values, sort_keys=True, ensure_ascii=False, separators=(",", ":") + ) + return kind + ":" + hashlib.sha256(encoded.encode()).hexdigest() + + +class ConversationIdentity(Value): + """Frozen grouping attestation from retained canonical identity, before retrieval.""" + + kind: Literal["session", "email_thread"] + native_id: str = Field(min_length=1) + + +class CorpusRecord(Value): + """One exact destination locator and its preserved stable source identity.""" + + record_id: UUID + sync_id: UUID + revision: int = Field(ge=1) + source_id: Identifier + provider: Identifier + identity: RecordIdentity + conversation: ConversationIdentity | None = None + + @property + def evaluation_id(self) -> str: + """Keep native IDs account-scoped, including their original container.""" + return _identifier( + "original", + [self.source_id, self.provider, self.identity.model_dump(mode="json")], + ) + + @property + def card_id(self) -> str: + """Use frozen membership even when a different original wins the ranking.""" + if self.conversation is None: + return self.evaluation_id + return _identifier( + "card", + [ + self.source_id, + self.provider, + self.conversation.kind, + self.conversation.native_id, + ], + ) + + +def delivered_result( + query_id: str, + response: OwnedSearchResponse, + records: tuple[CorpusRecord, ...], + *, + unit: Literal["card", "displayed_original"], +) -> Result: + """Preserve card order; flatten only explicitly delivered originals when requested.""" + census = {record.record_id: record for record in records} + if len(census) != len(records): + raise ValueError("Duplicate destination record in corpus census") + + def reference(match: OwnedSearchMatch) -> CorpusRecord: + record = census[match.record_id] + if ( + record.sync_id != match.sync_id + or record.revision != match.revision + or record.provider != match.provider + or record.identity != match.identity + ): + raise ValueError("Delivered match conflicts with frozen source identity") + return record + + ranking = [] + for hit in response.items: + original = reference(hit) + conversation = ( + None + if hit.group is None + else ConversationIdentity( + kind=hit.group.kind, native_id=hit.group.native_id + ) + ) + if conversation != original.conversation: + raise ValueError( + "Delivered group conflicts with frozen conversation membership" + ) + additional = () if hit.group is None else hit.group.additional_matches + for match in additional: + member = reference(match) + if (member.source_id, member.provider, member.sync_id) != ( + original.source_id, + original.provider, + original.sync_id, + ): + raise ValueError("Conversation contains a different source identity") + if member.conversation != conversation: + raise ValueError( + "Additional match conflicts with frozen conversation membership" + ) + if unit == "card" and hit.group is not None: + ranking.append(original.card_id) + else: + ranking.append(original.evaluation_id) + if unit == "displayed_original": + ranking.extend(reference(match).evaluation_id for match in additional) + return Result( + query_id=query_id, + status="partial" + if response.retrieval_incomplete or response.engine_partial + else "success", + record_ids=tuple(ranking), + ) diff --git a/evaluation/replay.py b/evaluation/replay.py new file mode 100644 index 0000000000..a9c8bdaf9c --- /dev/null +++ b/evaluation/replay.py @@ -0,0 +1,350 @@ +"""Replay frozen queries over HTTP against an already qualified, retained corpus. + +No capture, indexing, SQL, provider calls or relevance judgments happen here. +""" + +import hashlib +import json +import os +from pathlib import Path +from time import perf_counter +from typing import Literal +from uuid import UUID + +import httpx +from pydantic import BaseModel, Field, model_validator + +from airweave.domains.entities.canonical.extraction_models import ExtractionCoverage +from airweave.domains.entities.canonical.models import RecordPage +from airweave.domains.entities.canonical.requests import RecordIdentity +from airweave.domains.search.owned_models import ( + OwnedRanking, + OwnedSearchRequest, + OwnedSearchResponse, +) +from airweave.domains.search.retrieval_strategy import RetrievalStrategy +from evaluation.owned_retrieval import CorpusRecord, delivered_result +from evaluation.retrieval import ( + Dataset, + Identifier, + Result, + Run, + Timing, + Value, + pool_unjudged, +) + + +class FrozenRecord(CorpusRecord): + """Replay-only proof of one qualified locator's capture and extraction revision.""" + + capture_hash: str = Field(pattern=r"^[a-f0-9]{64}$") + indexed_pipeline_version: int = Field(ge=1) + extraction: ExtractionCoverage + parent: RecordIdentity | None = None + + +class FrozenCorpus(Value): + """Complete eligible record set in each explicitly selected source sync.""" + + corpus_id: Identifier + organization_id: UUID + sync_ids: tuple[UUID, ...] = Field(min_length=1, max_length=20) + records: tuple[FrozenRecord, ...] = Field(min_length=1) + + @model_validator(mode="after") + def validate_scope(self): + """Require unique locators and stable source scopes without silently filtering.""" + if len(set(self.sync_ids)) != len(self.sync_ids): + raise ValueError("Duplicate source scope") + if len({r.record_id for r in self.records}) != len(self.records): + raise ValueError("Duplicate corpus locator") + if len({r.evaluation_id for r in self.records}) != len(self.records): + raise ValueError("Duplicate native source identity") + scopes: dict[UUID, tuple[str, str]] = {} + for record in self.records: + if record.sync_id not in self.sync_ids: + raise ValueError("Corpus record is outside selected sources") + identity = (record.source_id, record.provider) + if scopes.setdefault(record.sync_id, identity) != identity: + raise ValueError("Source scope has conflicting identity") + if len(set(scopes.values())) != len(scopes): + raise ValueError("Native source identity spans multiple destination syncs") + return self + + +class ReplayConfiguration(Value): + """Explicit request settings; system/model description is operator supplied.""" + + system: str = Field(min_length=1) + mode: RetrievalStrategy + unit: Literal["card", "displayed_original"] = "card" + limit: int = Field(default=20, ge=1, le=200) + + +class Observation(Value): + """One outcome, including failures, with raw HTTP bytes retained separately.""" + + query_id: Identifier + request_id: Identifier + status: Literal["success", "partial", "error", "timeout"] + http_status: int | None = None + ranking: OwnedRanking | None = None + candidate_window_full: bool | None = None + engine_partial: bool | None = None + retrieval_incomplete: bool | None = None + + +class ReplayEvidence(Value): + """Evidence and practical limits; invalid corpus checks never produce a scored run.""" + + outcome: Literal["verified", "invalid"] + configuration: ReplayConfiguration + corpus_sha256: str + dataset_sha256: str + census_before: bool + census_after: bool + observations: tuple[Observation, ...] + limitations: tuple[str, ...] = ( + "Before/after census traversals are live and non-atomic; transient changes can escape detection.", + "Record availability is metadata/permission evidence, not physical blob integrity.", + "Extraction is checked for delivered matches; other records retain qualification-time proof.", + "System/model description is supplied by the operator, not independently verified by the API.", + "Timings cover one HTTP request each, with unknown cache state and no retries.", + ) + + +class CorpusMismatch(ValueError): + """The supplied qualified corpus no longer describes the destination.""" + + +def save_bytes(path: Path, data: bytes) -> None: + """Never overwrite evidence or rely on a permissive caller umask.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as output: + output.write(data) + + +def save_model(path: Path, model: BaseModel) -> None: + """Retain validated inputs and results privately.""" + save_bytes(path, (model.model_dump_json(indent=2) + "\n").encode()) + + +async def verify_census( + client: httpx.AsyncClient, corpus: FrozenCorpus, output: Path +) -> None: + """Compare paginated active, content-available records with the complete frozen set.""" + expected = {record.record_id: record for record in corpus.records} + eligible: set[UUID] = set() + for scope_index, sync_id in enumerate(corpus.sync_ids): + seen: set[UUID] = set() + cursors: set[str] = set() + cursor = None + previous_id: UUID | None = None + page_index = 0 + while True: + params: dict[str, str | int] = {"state": "active", "limit": 500} + if cursor is not None: + params["cursor"] = cursor + response = await client.get(f"sync/{sync_id}/records", params=params) + save_bytes( + output / f"scope-{scope_index:02d}-page-{page_index:05d}.json", + response.content, + ) + response.raise_for_status() + page = RecordPage.model_validate_json(response.content) + for live in page.records: + if ( + live.sync_id != sync_id + or live.id in seen + or (previous_id is not None and live.id <= previous_id) + ): + raise CorpusMismatch( + "Census traversal has invalid scope or ordering" + ) + seen.add(live.id) + previous_id = live.id + if live.deleted_at is not None: + raise CorpusMismatch("Active traversal returned a deleted record") + if live.content_access != "available": + continue + frozen = expected.get(live.id) + if frozen is None or ( + frozen.sync_id != live.sync_id + or frozen.identity != live.identity + or frozen.parent != live.parent + or frozen.revision != live.revision + or frozen.capture_hash != live.capture_hash + or live.indexed_revision != frozen.revision + or live.indexed_pipeline_version != frozen.indexed_pipeline_version + ): + raise CorpusMismatch("Destination differs from qualified corpus") + eligible.add(live.id) + if page.has_more != (page.next_cursor is not None): + raise CorpusMismatch("Census continuation is contradictory") + if not page.has_more: + break + if not page.records or page.next_cursor in cursors: + raise CorpusMismatch("Census continuation did not advance") + cursor = page.next_cursor + cursors.add(cursor) + page_index += 1 + if eligible != set(expected): + raise CorpusMismatch("Qualified corpus records are missing or unavailable") + + +def validate_extraction(response: OwnedSearchResponse, corpus: FrozenCorpus) -> None: + """Check the exact extraction proof of every explicitly delivered original.""" + expected = {record.record_id: record for record in corpus.records} + for hit in response.items: + additional = () if hit.group is None else hit.group.additional_matches + for match in (hit, *additional): + record = expected.get(match.record_id) + if record is None or match.extraction != record.extraction: + raise CorpusMismatch( + "Delivered extraction differs from qualified proof" + ) + + +async def replay( + client: httpx.AsyncClient, + dataset: Dataset, + corpus: FrozenCorpus, + configuration: ReplayConfiguration, + output: Path, +) -> Run: + """One sequential trial, with raw failures retained and no corpus rebuilds.""" + if dataset.corpus_id != corpus.corpus_id: + raise CorpusMismatch("Dataset and corpus identities differ") + ids = { + r.card_id if configuration.unit == "card" else r.evaluation_id + for r in corpus.records + } + for query in dataset.queries: + units = {tag for tag in query.tags if tag.startswith("unit:")} + if units != {f"unit:{configuration.unit}"} or any( + judgment.record_id not in ids for judgment in query.judgments + ): + raise CorpusMismatch( + "Dataset labels do not describe the selected delivery unit" + ) + requests = tuple( + OwnedSearchRequest( + query=q.text, + sync_ids=corpus.sync_ids, + mode=configuration.mode, + limit=configuration.limit, + ) + for q in dataset.queries + ) + output.mkdir(mode=0o700, parents=False, exist_ok=False) + save_model(output / "dataset.json", dataset) + save_model(output / "corpus.json", corpus) + before = after = False + observations: list[Observation] = [] + results: list[Result] = [] + timings: list[Timing] = [] + try: + (output / "before").mkdir(mode=0o700) + await verify_census(client, corpus, output / "before") + before = True + for index, (query, request) in enumerate( + zip(dataset.queries, requests, strict=True) + ): + request_id = f"request-{index:05d}" + save_model(output / f"{request_id}-input.json", request) + started = perf_counter() + status_code = None + parsed = None + abort: CorpusMismatch | None = None + try: + response = await client.post( + "sync/search", json=request.model_dump(mode="json") + ) + duration = (perf_counter() - started) * 1000 + status_code = response.status_code + save_bytes(output / f"{request_id}-response.json", response.content) + response.raise_for_status() + parsed = OwnedSearchResponse.model_validate_json(response.content) + if len(parsed.items) > request.limit: + raise ValueError("Delivered card count exceeds requested limit") + validate_extraction(parsed, corpus) + try: + result = delivered_result( + query.id, parsed, corpus.records, unit=configuration.unit + ) + except (ValueError, KeyError) as exc: + raise CorpusMismatch( + "Delivered identity differs from qualified corpus" + ) from exc + except httpx.TimeoutException: + duration = (perf_counter() - started) * 1000 + result = Result(query_id=query.id, status="timeout") + except httpx.HTTPError: + duration = (perf_counter() - started) * 1000 + result = Result(query_id=query.id, status="error") + except CorpusMismatch as exc: + abort = exc + result = Result(query_id=query.id, status="error") + except ValueError: + result = Result(query_id=query.id, status="error") + results.append(result) + timings.append( + Timing( + request_id=request_id, + query_id=query.id, + phase="end_to_end", + condition="unknown", + status=result.status, + duration_ms=duration, + ) + ) + observation = Observation( + query_id=query.id, + request_id=request_id, + status=result.status, + http_status=status_code, + ranking=None if parsed is None else parsed.ranking, + candidate_window_full=None + if parsed is None + else parsed.candidate_window_full, + engine_partial=None if parsed is None else parsed.engine_partial, + retrieval_incomplete=None + if parsed is None + else parsed.retrieval_incomplete, + ) + observations.append(observation) + save_model(output / f"{request_id}-outcome.json", observation) + if abort is not None: + raise abort + (output / "after").mkdir(mode=0o700) + await verify_census(client, corpus, output / "after") + after = True + finally: + save_model( + output / "evidence.json", + ReplayEvidence( + outcome="verified" if after else "invalid", + configuration=configuration, + corpus_sha256=hashlib.sha256( + corpus.model_dump_json().encode() + ).hexdigest(), + dataset_sha256=dataset.fingerprint(), + census_before=before, + census_after=after, + observations=tuple(observations), + ), + ) + run = Run( + system=configuration.system, + corpus_id=corpus.corpus_id, + dataset_sha256=dataset.fingerprint(), + results=tuple(results), + timings=tuple(timings), + ) + save_model(output / "run.json", run) + save_bytes( + output / "unjudged.json", + (json.dumps(pool_unjudged(dataset, (run,)), indent=2) + "\n").encode(), + ) + return run diff --git a/evaluation/replay_cli.py b/evaluation/replay_cli.py new file mode 100644 index 0000000000..875a164cb4 --- /dev/null +++ b/evaluation/replay_cli.py @@ -0,0 +1,156 @@ +"""Replay queries against an already-qualified retained owned-search corpus.""" + +import asyncio +import sys +from pathlib import Path + +import httpx +from pydantic import ValidationError, field_validator + +from evaluation.native_import_cli import ( + MAX_INPUT_BYTES, + Parser, + PublishArguments, + Settings, +) +from evaluation.replay import ( + CorpusMismatch, + FrozenCorpus, + ReplayConfiguration, + replay, + save_model, +) +from evaluation.retrieval import Dataset, summarize + + +class ReplayArguments(ReplayConfiguration): + """Explicit HTTP destination and frozen input files; credentials stay in env.""" + + url: str + dataset: Path + census: Path + output: Path + report: bool = False + + @field_validator("url") + @classmethod + def destination_url(cls, value: str) -> str: + """Reuse the native publisher's destination and credential transport boundary.""" + return PublishArguments.destination_url(value) + + +def parse_args(argv: list[str] | None) -> ReplayArguments: + """Require explicit corpus, request mode and system descriptor.""" + parser = Parser(description=__doc__) + parser.add_argument( + "--url", required=True, help="API root, e.g. https://host/api/v1/" + ) + parser.add_argument( + "--dataset", required=True, help="Frozen Dataset JSON (max 32 MiB)" + ) + parser.add_argument( + "--census", required=True, help="Qualified FrozenCorpus JSON (max 32 MiB)" + ) + parser.add_argument("--output", required=True, help="New private run directory") + parser.add_argument( + "--system", required=True, help="Operator-supplied system/model descriptor" + ) + parser.add_argument( + "--mode", required=True, choices=("keyword", "semantic", "hybrid") + ) + parser.add_argument( + "--unit", default="card", choices=("card", "displayed_original") + ) + parser.add_argument("--limit", type=int, default=20) + parser.add_argument( + "--report", + action="store_true", + help="Also score using the existing offline evaluator", + ) + return ReplayArguments.model_validate(vars(parser.parse_args(argv))) + + +def read_input(path: Path) -> bytes: + """Read a bounded caller-owned file without printing its private content.""" + with path.open("rb") as staged: + data = staged.read(MAX_INPUT_BYTES + 1) + if len(data) > MAX_INPUT_BYTES: + raise ValueError("Input exceeds the size limit") + return data + + +async def execute(arguments: ReplayArguments, settings: Settings) -> str: + """Use the existing API models and offline scorer, without server configuration.""" + dataset = Dataset.model_validate_json(read_input(arguments.dataset)) + corpus = FrozenCorpus.model_validate_json(read_input(arguments.census)) + configuration = ReplayConfiguration( + system=arguments.system, + mode=arguments.mode, + unit=arguments.unit, + limit=arguments.limit, + ) + async with httpx.AsyncClient( + base_url=arguments.url, + headers={ + "X-API-Key": settings.api_key.get_secret_value(), + "X-Organization-ID": str(corpus.organization_id), + }, + follow_redirects=False, + trust_env=False, + timeout=httpx.Timeout(60, connect=10), + ) as client: + run = await replay(client, dataset, corpus, configuration, arguments.output) + if arguments.report: + save_model(arguments.output / "report.json", summarize(dataset, run)) + failed = sum(result.status in {"error", "timeout"} for result in run.results) + return f"Corpus checks passed; retained {len(run.results)} query outcomes ({failed} failed)." + + +def main(argv: list[str] | None = None) -> int: + """Emit static errors; private query text, response bodies and secrets stay in files.""" + try: + arguments = parse_args(argv) + summary = asyncio.run(execute(arguments, Settings())) + except CorpusMismatch: + print( + "Corpus or delivered proof differs from frozen input; comparison rejected. Retain evidence.", + file=sys.stderr, + ) + return 4 + except httpx.HTTPError: + print( + "Census HTTP verification failed; comparison rejected. Retain evidence.", + file=sys.stderr, + ) + return 3 + except (ValidationError, ValueError, OSError): + print( + "Invalid configuration, frozen input or output. Check --help; inputs are limited to 32 MiB.", + file=sys.stderr, + ) + return 2 + except ModuleNotFoundError: + print( + "Offline scoring dependency unavailable; a verified run may already be retained. " + "Install the pinned evaluation dependencies before scoring.", + file=sys.stderr, + ) + return 5 + except KeyboardInterrupt: + print( + "Interrupted; retain private evidence. No corpus mutations were requested.", + file=sys.stderr, + ) + return 130 + except Exception: + print( + "Unexpected replay failure; retain private evidence. No comparison is established.", + file=sys.stderr, + ) + return 1 + print(summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evaluation/retrieval.py b/evaluation/retrieval.py new file mode 100644 index 0000000000..f7b3c37fcd --- /dev/null +++ b/evaluation/retrieval.py @@ -0,0 +1,391 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["ir-measures==0.4.3", "pydantic==2.11.9"] +# /// +"""Offline relevance and latency reports, independent of production credentials.""" + +import argparse +import hashlib +import json +import math +import os +from collections import defaultdict +from pathlib import Path +from statistics import mean +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +Identifier = Annotated[str, Field(min_length=1, pattern=r"^\S+$")] +Milliseconds = Annotated[float, Field(ge=0, allow_inf_nan=False)] + + +class Value(BaseModel): + """Strict immutable file boundary.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Judgment(Value): + """Record-level relevance; zero is judged irrelevant, absence is unjudged.""" + + record_id: Identifier + relevance: int = Field(ge=0, le=3, strict=True) + assessor: Literal["human", "agent", "synthetic"] + + +class DuplicateGroup(Value): + """Distinct originals judged redundant for this query's intent and filters.""" + + record_ids: tuple[Identifier, ...] = Field(min_length=2) + assessor: Literal["human", "agent", "synthetic"] + + +class Query(Value): + """A fixed intent with explicit known-answer or no-answer expectation.""" + + id: Identifier + text: str = Field(min_length=1) + tags: tuple[Identifier, ...] = () + expectation: Literal["relevant_records", "no_answer"] + expectation_assessor: Literal["human", "agent", "synthetic"] + judgments: tuple[Judgment, ...] + duplicate_groups: tuple[DuplicateGroup, ...] = () + + @model_validator(mode="after") + def validate_judgments(self): + """Never silently treat a query with no positive judgments as an answerable query.""" + ids = [j.record_id for j in self.judgments] + if len(ids) != len(set(ids)): + raise ValueError("Duplicate record judgment") + positive = any(j.relevance > 0 for j in self.judgments) + if positive != (self.expectation == "relevant_records"): + raise ValueError("Query expectation conflicts with positive judgments") + if not self.text.strip() or len(self.tags) != len(set(self.tags)): + raise ValueError("Query text must be nonblank and tags unique") + grades = {j.record_id: j.relevance for j in self.judgments} + assigned = set() + for group in self.duplicate_groups: + for record in group.record_ids: + if record in assigned: + raise ValueError( + "Duplicate-group members must be disjoint and unique" + ) + if record not in grades: + raise ValueError( + "Duplicate-group members require relevance judgments" + ) + assigned.add(record) + if len({grades[record] for record in group.record_ids}) != 1: + raise ValueError("Duplicate-group members must have equal relevance") + return self + + +class Dataset(Value): + """Versioned corpus and labels; real content belongs in a private operator directory.""" + + corpus_id: Identifier + version: Identifier + queries: tuple[Query, ...] = Field(min_length=1) + + @model_validator(mode="after") + def unique_queries(self): + """Prevent duplicate intents from silently weighting the aggregate.""" + if len({q.id for q in self.queries}) != len(self.queries): + raise ValueError("Duplicate query ID") + return self + + def fingerprint(self) -> str: + """Bind a run to the exact validated corpus/query/judgment specification.""" + encoded = json.dumps( + self.model_dump(mode="json"), sort_keys=True, ensure_ascii=False + ) + return hashlib.sha256(encoded.encode()).hexdigest() + + +class Result(Value): + """One delivered ranking per query, before any evaluator-side filtering.""" + + query_id: Identifier + status: Literal["success", "partial", "error", "timeout"] + record_ids: tuple[Identifier, ...] = () + + @model_validator(mode="after") + def validate_ranking(self): + """Require record-level deduplication and unambiguous failed outcomes.""" + if len(self.record_ids) != len(set(self.record_ids)): + raise ValueError( + "Duplicate ranked record: evaluate the record-level response" + ) + if self.status in {"error", "timeout"} and self.record_ids: + raise ValueError("Failed requests cannot contain a delivered ranking") + return self + + +class Timing(Value): + """One observed phase, with explicit request identity and cache condition.""" + + request_id: Identifier + query_id: Identifier + phase: Literal["end_to_end", "retrieval", "reranking", "read"] + condition: Literal["cold", "warm", "unknown"] + status: Literal["success", "partial", "error", "timeout"] + duration_ms: Milliseconds + + +class Run(Value): + """A named system configuration and its observed outputs; no model-score assumptions.""" + + system: str = Field(min_length=1) + dataset_sha256: str = Field(pattern=r"^[a-f0-9]{64}$") + corpus_id: Identifier + results: tuple[Result, ...] + timings: tuple[Timing, ...] = () + + @model_validator(mode="after") + def unique_observations(self): + """Reject accidentally duplicated queries or phase measurements.""" + if len({r.query_id for r in self.results}) != len(self.results): + raise ValueError("Duplicate query result") + keys = [(t.request_id, t.phase) for t in self.timings] + if len(keys) != len(set(keys)): + raise ValueError("Duplicate request phase timing") + requests: dict[str, tuple[str, str]] = {} + for timing in self.timings: + identity = (timing.query_id, timing.condition) + if requests.setdefault(timing.request_id, identity) != identity: + raise ValueError("Request timing identity changed between phases") + return self + + +class DuplicateReport(Value): + """Known redundant slots; unassigned records are not declared unique.""" + + cutoff: Literal[20] = 20 + grouped_records: int + duplicate_slots: int + unassigned_records: int + + +class QueryReport(Value): + """Scores retain failures and label provenance, without copying private query text.""" + + query_id: str + tags: tuple[str, ...] + status: str + expectation: str + assessors: tuple[str, ...] + scores: dict[str, float] + returned: int + judged_in_top_10: int + unjudged_in_top_10: int + correctly_empty: bool | None + duplicates: DuplicateReport | None + + +class LatencyReport(Value): + """Percentiles by phase/condition/outcome; failures never disappear into fast successes.""" + + phase: str + condition: str + status: str + samples: int + p50_ms: float + p95_ms: float + max_ms: float + + +class Report(Value): + """Descriptive evidence, not a production-readiness verdict.""" + + system: str + corpus_id: str + dataset_sha256: str + answerable_queries: int + aggregate: dict[str, float] + queries: tuple[QueryReport, ...] + latency: tuple[LatencyReport, ...] + warnings: tuple[str, ...] + + +def validate_run(dataset: Dataset, run: Run) -> None: + """Require every observation to belong to the exact frozen evaluation dataset.""" + if ( + run.corpus_id != dataset.corpus_id + or run.dataset_sha256 != dataset.fingerprint() + ): + raise ValueError("Run does not match the exact dataset and corpus") + query_ids = {q.id for q in dataset.queries} + if {r.query_id for r in run.results} != query_ids: + raise ValueError( + "Every dataset query needs exactly one result, including failures" + ) + if any(t.query_id not in query_ids for t in run.timings): + raise ValueError("Timing references an unknown query") + + +def pool_unjudged( + dataset: Dataset, runs: tuple[Run, ...] +) -> dict[str, tuple[str, ...]]: + """Union delivered candidates needing assessment; never invent negative labels.""" + for run in runs: + validate_run(dataset, run) + return { + query.id: tuple( + sorted( + { + record + for run in runs + for result in run.results + if result.query_id == query.id + for record in result.record_ids + } + - {judgment.record_id for judgment in query.judgments} + ) + ) + for query in dataset.queries + } + + +def summarize(dataset: Dataset, run: Run) -> Report: + """Use established IR measures, preserving API rank order with unique synthetic scores.""" + import ir_measures + + validate_run(dataset, run) + measures = [ + ir_measures.nDCG @ 10, + ir_measures.RR @ 10, + ir_measures.P @ 5, + ir_measures.R @ 20, + ] + names = dict( + zip(measures, ("nDCG@10", "MRR@10", "Precision@5", "Recall@20"), strict=True) + ) + reports = [] + results = {r.query_id: r for r in run.results} + for query in dataset.queries: + result = results[query.id] + scores = {} + if query.expectation == "relevant_records": + qrels = [ + ir_measures.Qrel(query.id, j.record_id, j.relevance) + for j in query.judgments + ] + ranked = [ + ir_measures.ScoredDoc(query.id, record, float(-rank)) + for rank, record in enumerate(result.record_ids) + ] + scores = { + names[m]: float(v) + for m, v in ir_measures.calc_aggregate(measures, qrels, ranked).items() + } + judged = {j.record_id for j in query.judgments} + top = result.record_ids[:10] + membership = { + record: index + for index, group in enumerate(query.duplicate_groups) + for record in group.record_ids + } + duplicate_window = result.record_ids[:20] + assigned = [membership[r] for r in duplicate_window if r in membership] + reports.append( + QueryReport( + query_id=query.id, + tags=query.tags, + status=result.status, + expectation=query.expectation, + assessors=tuple( + sorted( + {query.expectation_assessor} + | {j.assessor for j in query.judgments} + | {g.assessor for g in query.duplicate_groups} + ) + ), + scores=scores, + returned=len(result.record_ids), + judged_in_top_10=sum(record in judged for record in top), + unjudged_in_top_10=sum(record not in judged for record in top), + correctly_empty=(result.status == "success" and not result.record_ids) + if query.expectation == "no_answer" + else None, + duplicates=DuplicateReport( + grouped_records=len(assigned), + duplicate_slots=len(assigned) - len(set(assigned)), + unassigned_records=len(duplicate_window) - len(assigned), + ) + if query.duplicate_groups + else None, + ) + ) + answerable = [q for q in reports if q.expectation == "relevant_records"] + groups: dict[tuple[str, str, str], list[float]] = defaultdict(list) + for timing in run.timings: + groups[(timing.phase, timing.condition, timing.status)].append( + timing.duration_ms + ) + latencies = [] + for (phase, condition, status), samples in sorted(groups.items()): + samples.sort() + # Nearest-rank percentiles are defined even for one observation; counts remain visible. + latencies.append( + LatencyReport( + phase=phase, + condition=condition, + status=status, + samples=len(samples), + p50_ms=samples[math.ceil(0.5 * len(samples)) - 1], + p95_ms=samples[math.ceil(0.95 * len(samples)) - 1], + max_ms=samples[-1], + ) + ) + warnings = [ + "Recall is against judged relevant records, not all connected-account content.", + "Unjudged records receive no relevance credit; pool and judge them before conclusions.", + "Latency uses nearest-rank percentiles; small samples do not establish tail reliability.", + "Duplicate counts use explicit query-specific labels; unassigned records may also repeat.", + "Relevance metrics score the delivered ranking unchanged, including distinct redundant originals.", + ] + if any(assessor != "human" for q in reports for assessor in q.assessors): + warnings.append( + "Dataset includes agent or synthetic judgments, not exclusively human labels." + ) + if not run.timings: + warnings.append("No latency measurements supplied.") + return Report( + system=run.system, + corpus_id=run.corpus_id, + dataset_sha256=run.dataset_sha256, + answerable_queries=len(answerable), + aggregate={ + name: mean(q.scores[name] for q in answerable) for name in names.values() + } + if answerable + else {}, + queries=tuple(reports), + latency=tuple(latencies), + warnings=tuple(warnings), + ) + + +def main() -> None: + """Print a dataset fingerprint or save a report without overwriting existing artifacts.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dataset", type=Path) + parser.add_argument("--run", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + dataset = Dataset.model_validate_json(args.dataset.read_bytes()) + if args.run is None: + print(dataset.fingerprint()) + return + if args.output is None: + parser.error("--output is required with --run") + report = summarize(dataset, Run.model_validate_json(args.run.read_bytes())) + # Private reports can contain record IDs; do not print them or use world-readable defaults. + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as output: + output.write(report.model_dump_json(indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/evaluation/tests/test_retrieval.py b/evaluation/tests/test_retrieval.py new file mode 100644 index 0000000000..be54f222fd --- /dev/null +++ b/evaluation/tests/test_retrieval.py @@ -0,0 +1,260 @@ +"""Small adversarial datasets exercise evaluation honesty, not production relevance.""" + +import pytest +from pydantic import ValidationError + +from evaluation.retrieval import ( + Dataset, + DuplicateGroup, + Judgment, + Query, + Result, + Run, + Timing, + pool_unjudged, + summarize, +) + + +def corpus(): + return Dataset( + corpus_id="fixture-v1", + version="labels-v1", + queries=( + Query( + id="known", + text="परियोजना बैठक", + expectation="relevant_records", + expectation_assessor="synthetic", + judgments=( + Judgment(record_id="meeting", relevance=3, assessor="synthetic"), + Judgment(record_id="spam", relevance=0, assessor="synthetic"), + ), + ), + Query( + id="failed", + text="a known document", + expectation="relevant_records", + expectation_assessor="synthetic", + judgments=( + Judgment(record_id="document", relevance=1, assessor="synthetic"), + ), + ), + Query( + id="absent", + text="nonexistent meeting", + expectation="no_answer", + expectation_assessor="synthetic", + judgments=(), + ), + ), + ) + + +def run(dataset, **changes): + values = dict( + system="synthetic", + corpus_id=dataset.corpus_id, + dataset_sha256=dataset.fingerprint(), + results=( + Result( + query_id="known", + status="success", + record_ids=("spam", "meeting", "unknown"), + ), + Result(query_id="failed", status="timeout"), + Result(query_id="absent", status="error"), + ), + ) + return Run(**(values | changes)) + + +def test_failed_query_counts_and_unjudged_is_not_labeled_irrelevant(): + dataset = corpus() + report = summarize(dataset, run(dataset)) + assert report.answerable_queries == 2 + assert report.aggregate["MRR@10"] == 0.25 + assert report.aggregate["Precision@5"] == 0.1 + assert report.aggregate["Recall@20"] == 0.5 + assert 0 < report.aggregate["nDCG@10"] < 0.5 + assert report.queries[0].judged_in_top_10 == 2 + assert report.queries[0].unjudged_in_top_10 == 1 + assert report.queries[2].correctly_empty is False + assert any("synthetic" in warning for warning in report.warnings) + assert report.queries[0].duplicates is None + + +def test_latency_conditions_failures_and_tail_are_separate(): + dataset = corpus() + timings = tuple( + Timing( + request_id=f"warm-{i}", + query_id="known", + phase="end_to_end", + condition="warm", + status="success", + duration_ms=i, + ) + for i in range(1, 21) + ) + ( + Timing( + request_id="cold", + query_id="known", + phase="end_to_end", + condition="cold", + status="success", + duration_ms=500, + ), + Timing( + request_id="timeout", + query_id="failed", + phase="end_to_end", + condition="warm", + status="timeout", + duration_ms=10000, + ), + ) + report = summarize(dataset, run(dataset, timings=timings)) + warm = next( + row + for row in report.latency + if row.condition == "warm" and row.status == "success" + ) + assert (warm.samples, warm.p50_ms, warm.p95_ms, warm.max_ms) == (20, 10, 19, 20) + assert len(report.latency) == 3 + + +def test_missing_query_or_changed_labels_cannot_improve_score_silently(): + dataset = corpus() + original = run(dataset) + with pytest.raises(ValueError, match="Every dataset query"): + summarize( + dataset, original.model_copy(update={"results": original.results[:1]}) + ) + with pytest.raises(ValueError, match="exact dataset"): + summarize(dataset.model_copy(update={"version": "new-labels"}), original) + with pytest.raises(ValidationError, match="Duplicate ranked record"): + Result(query_id="known", status="success", record_ids=("meeting", "meeting")) + with pytest.raises(ValidationError): + Timing( + request_id="x", + query_id="known", + phase="end_to_end", + condition="warm", + status="success", + duration_ms=float("nan"), + ) + + +def test_pool_unions_only_unjudged_delivered_records_and_preserves_dataset_binding(): + dataset = corpus() + original = run(dataset) + additional = run( + dataset, + results=( + Result( + query_id="known", + status="success", + record_ids=("meeting", "other", "unknown"), + ), + Result(query_id="failed", status="timeout"), + Result(query_id="absent", status="success", record_ids=("candidate",)), + ), + ) + assert pool_unjudged(dataset, (original, additional)) == { + "known": ("other", "unknown"), + "failed": (), + "absent": ("candidate",), + } + assert not any( + j.record_id == "unknown" for q in dataset.queries for j in q.judgments + ) + with pytest.raises(ValueError, match="exact dataset"): + pool_unjudged(dataset.model_copy(update={"version": "changed"}), (original,)) + + +def test_no_answer_success_is_separate_from_answerable_metrics(): + dataset = Dataset(corpus_id="empty", version="v1", queries=(corpus().queries[2],)) + observation = Run( + system="empty", + corpus_id=dataset.corpus_id, + dataset_sha256=dataset.fingerprint(), + results=(Result(query_id="absent", status="success"),), + ) + report = summarize(dataset, observation) + assert report.aggregate == {} + assert report.queries[0].correctly_empty is True + + +def test_redundant_originals_remain_in_scores_and_unknown_diversity_stays_unknown(): + query = Query( + id="copies", + text="Find the original note", + expectation="relevant_records", + expectation_assessor="human", + judgments=tuple( + Judgment(record_id=record, relevance=3, assessor="human") + for record in ("a", "b", "c") + ), + duplicate_groups=( + DuplicateGroup(record_ids=("a", "b", "c"), assessor="agent"), + ), + ) + dataset = Dataset(corpus_id="copies", version="v1", queries=(query,)) + observation = Run( + system="copies", + corpus_id=dataset.corpus_id, + dataset_sha256=dataset.fingerprint(), + results=( + Result( + query_id=query.id, + status="partial", + record_ids=("a", "b", "c", "unknown"), + ), + ), + ) + report = summarize(dataset, observation) + row = report.queries[0] + assert row.scores["nDCG@10"] == 1 + assert row.scores["Precision@5"] == 0.6 + assert row.returned == 4 and row.status == "partial" + assert row.duplicates.grouped_records == 3 + assert row.duplicates.duplicate_slots == 2 + assert row.duplicates.unassigned_records == 1 + assert row.assessors == ("agent", "human") + assert any("not exclusively human" in warning for warning in report.warnings) + outside = observation.model_copy( + update={ + "results": ( + Result( + query_id=query.id, + status="success", + record_ids=tuple(f"unassigned-{i}" for i in range(20)) + ("a", "b"), + ), + ) + } + ) + cutoff = summarize(dataset, outside).queries[0].duplicates + assert ( + cutoff.grouped_records, + cutoff.duplicate_slots, + cutoff.unassigned_records, + ) == (0, 0, 20) + unlabeled = query.model_copy(update={"duplicate_groups": ()}) + assert ( + dataset.fingerprint() + != dataset.model_copy(update={"queries": (unlabeled,)}).fingerprint() + ) + + +@pytest.mark.parametrize( + "members", + [(("meeting", "meeting"),), (("meeting", "missing"),), (("meeting", "spam"),)], +) +def test_duplicate_labels_cannot_repeat_invent_or_contradict_judgments(members): + fields = corpus().queries[0].model_dump() + fields["duplicate_groups"] = [ + {"record_ids": records, "assessor": "synthetic"} for records in members + ] + with pytest.raises(ValidationError): + Query.model_validate(fields) diff --git a/porter.yaml b/porter.yaml new file mode 100644 index 0000000000..7c2f52627d --- /dev/null +++ b/porter.yaml @@ -0,0 +1,55 @@ +# Application services only. Provision isolated dependencies and the environment +# group described in deploy/README.md before applying. Never attach almanac/dev +# wholesale: its database belongs to the existing product. +version: v2 +name: almanac-source-store +build: + method: docker + context: ./backend + dockerfile: ./backend/Dockerfile +envGroups: + - almanac-source-store-staging +env: + ENVIRONMENT: dev + LOCAL_DEVELOPMENT: "false" + AUTH_MODE: api_key + HEALTH_CRITICAL_PROBES: postgres,redis,temporal + ANALYTICS_ENABLED: "false" + STRIPE_ENABLED: "false" + STORAGE_BACKEND: aws + STORAGE_AWS_REGION: us-east-1 + DENSE_EMBEDDER: local_minilm + EMBEDDING_DIMENSIONS: "384" + SPARSE_EMBEDDER: fastembed_bm25 + SYNC_MAX_WORKERS: "2" + SYNC_THREAD_POOL_SIZE: "8" + TEMPORAL_GRACEFUL_SHUTDOWN_TIMEOUT: "90" +services: + - name: api + type: web + run: ./entrypoint.sh + port: 8001 + private: true + instances: 1 + cpuCores: 0.5 + ramMegabytes: 1024 + terminationGracePeriodSeconds: 60 + readinessCheck: + enabled: true + httpPath: /health/ready + timeoutSeconds: 10 + initialDelaySeconds: 15 + livenessCheck: + enabled: true + httpPath: /health/live + timeoutSeconds: 5 + initialDelaySeconds: 30 + - name: worker + type: worker + run: python -m airweave.domains.temporal.worker + instances: 1 + cpuCores: 1 + ramMegabytes: 2048 + terminationGracePeriodSeconds: 120 +autoRollback: + enabled: true diff --git a/vespa/README.md b/vespa/README.md new file mode 100644 index 0000000000..46850b2d19 --- /dev/null +++ b/vespa/README.md @@ -0,0 +1,42 @@ +# Owned lexical snippets + +The base schema adds a query-time `query_snippet` dynamic summary sourced from +`textual_representation`. It does not replace stored text, embeddings, canonical +originals or reranker input. Owned search consumes marked fragments only for +keyword/hybrid requests; missing, unmarked or semantic results retain the bounded +chunk-prefix fallback. Highlight delimiters are removed and Vespa fragment +separators become plain ellipses. The strings are untrusted display text, not HTML. + +Vespa's default summary includes every summary-indexed field and fields declared +in any document summary. Keeping the default selection preserves child-schema +fields and canonical identity metadata alongside the new alias: +https://docs.vespa.ai/en/querying/document-summaries.html + +The source is an index/summary field, not an attribute. Vespa documents adding a +new-name non-attribute summary alias as requiring reindexing: +https://docs.vespa.ai/en/reference/schemas/schemas.html#modifying-schemas +Inspect prepare actions before activation and follow Vespa's reindex procedure. +This does not require provider recapture or changing embedding models. No schema +has been deployed by this code change, and existing indexes safely omit the field. + +Qualification gate: validate/deploy only an isolated application first; verify +inherited email/file schemas return both full text and the dynamic alias, with +unchanged canonical metadata. Exercise a keyword near the end of a long chunk, +hybrid lexical and vector-only matches, semantic-only fallback, and multilingual +text. Confirm full reranker input and stale-publication exclusion remain intact. +Qualification evidence (2026-10-02 UTC): the disposable CI application at +[c0632c2, run 36956517573](https://github.com/AlmanacCode/airweave/actions/runs/36956517573) +passed all six real-engine tests in 74.14 seconds. The new file/email case proves +default-summary alias availability, unchanged full chunks/identity/child URL, +near-end lexical highlighting, and adjacent Hindi/Chinese text. The canonical +projection test also verifies published search, semantic fallback, stale revision +exclusion, source access withdrawal, and deletion. These are synthetic correctness +checks, not corpus relevance measurements or an existing-index rollout. + +The preceding run at bd2739d passed the new summary case but failed an older +assertion requiring a particular body sentence for a filename-matching query. +Actual Vespa selected the matching filename and returned `` delimiters. +The follow-up normalizes those delimiters and checks lexical query evidence while +retaining the full-body assertion for semantic fallback. No retained local index +was deployed, reindexed, or otherwise changed. Existing-index prepare/reindex +compatibility remains a rollout gate. diff --git a/vespa/app/schemas/base_entity.sd b/vespa/app/schemas/base_entity.sd index 09dc6c306d..32e452ed37 100644 --- a/vespa/app/schemas/base_entity.sd +++ b/vespa/app/schemas/base_entity.sd @@ -34,7 +34,7 @@ schema base_entity { } field name type string { - indexing: attribute | summary + indexing: index | attribute | summary attribute: fast-search } @@ -66,6 +66,31 @@ schema base_entity { attribute: fast-search } + field airweave_system_metadata_canonical_record_type type string { + indexing: attribute | summary + attribute: fast-search + } + + field airweave_system_metadata_source_created_us type long { + indexing: attribute | summary + attribute: fast-search + } + + field airweave_system_metadata_source_updated_us type long { + indexing: attribute | summary + attribute: fast-search + } + + field airweave_system_metadata_source_created_known type int { + indexing: attribute | summary + attribute: fast-search + } + + field airweave_system_metadata_source_updated_known type int { + indexing: attribute | summary + attribute: fast-search + } + # ============================================================================= # System Metadata Fields (flattened) # ============================================================================= @@ -191,12 +216,18 @@ schema base_entity { # Default fieldset used by userInput() / unspecified text search fieldset default { - fields: textual_representation, payload + fields: name, textual_representation, payload } - # Note: We use Vespa's default summary (no explicit document-summary) - # which automatically includes ALL fields with "summary" in their indexing, - # including fields from child schemas (e.g., url from file_entity) + # The default summary includes this alias as well as all stored summary fields, + # including child-schema fields. Keep full text unchanged for reranking/readers. + # Adding this non-attribute summary alias requires Vespa reindexing; see README. + document-summary owned_snippets { + summary query_snippet type string { + source: textual_representation + dynamic + } + } # ========================================================================= # BASE RANK PROFILE - shared inputs and functions