"""Regression tests for conversation workspace ownership and isolation.""" from __future__ import annotations import sqlite3 from types import SimpleNamespace import pytest from blockbuster import BlockBuster from EvoScientist import paths from EvoScientist.scope_registry import ( ScopeAccessError, ScopeConflictError, ScopeRegistry, ) from EvoScientist.workspace_scope import ( DeferredScopedBackend, create_workspace_backend, provision_conversation_scope, require_scoped_runtime, verify_required_executor, workspace_isolation_mode, ) def test_workspace_isolation_environment_defaults_to_optional(monkeypatch): monkeypatch.delenv("EVOSCIENTIST_WORKSPACE_ISOLATION", raising=False) assert workspace_isolation_mode() == "optional" def test_deployed_backend_rejects_an_unscoped_runtime(): with pytest.raises(ScopeAccessError, match="require a workspace scope"): create_workspace_backend( SimpleNamespace(config={}), legacy_backend=lambda: pytest.fail("deployed run used legacy backend"), allow_unscoped_legacy=False, ) def test_non_deployed_backend_keeps_legacy_unscoped_runtime(): legacy_backend = object() backend = create_workspace_backend( SimpleNamespace(config={}), legacy_backend=lambda: legacy_backend, ) assert backend is legacy_backend def test_provision_is_idempotent_and_rejects_remap(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") first = registry.provision("deployment-a", "thread-a") second = registry.provision("deployment-a", "thread-a") assert first.scope_id == second.scope_id assert first.primary_owner_id == second.primary_owner_id with pytest.raises(ScopeConflictError): registry.provision( "deployment-a", "thread-a", scope_id="00000000-0000-4000-8000-000000000001" ) def test_scope_transition_uses_compare_and_set(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") record = registry.provision("deployment-a", "thread-a") active = registry.transition_scope( "deployment-a", record.scope_id, expected_revision=record.revision, state="active", ) assert active.revision == record.revision + 1 with pytest.raises(ScopeConflictError): registry.transition_scope( "deployment-a", record.scope_id, expected_revision=record.revision, state="deleting", ) def test_derived_owner_cannot_bind_resource_from_other_scope(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") first = registry.provision("deployment-a", "thread-a") second = registry.provision("deployment-a", "thread-b") owner_a = registry.register_owner( "deployment-a", first.scope_id, owner_type="async_thread", resource_id="child-thread", parent_owner_id=first.primary_owner_id, state="active", ) assert owner_a.resource_id == "child-thread" with pytest.raises(ScopeConflictError): registry.register_owner( "deployment-a", second.scope_id, owner_type="async_thread", resource_id="child-thread", parent_owner_id=second.primary_owner_id, state="active", ) def test_turn_reservation_is_idempotent_and_primary_run_validates_runtime(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") record = registry.provision("deployment-a", "thread-a") first = registry.reserve_turn( "deployment-a", record.scope_id, "00000000-0000-4000-8000-000000000001", "hash-a", ) second = registry.reserve_turn( "deployment-a", record.scope_id, "00000000-0000-4000-8000-000000000001", "hash-a", ) assert first.run_owner_id == second.run_owner_id assert ( registry.assert_runtime( "deployment-a", record.scope_id, "thread-a", first.run_owner_id ).primary_thread_id == "thread-a" ) bound = registry.bind_turn("deployment-a", record.scope_id, first.turn_id, "run-a") assert bound.run_id == "run-a" assert ( registry.assert_runtime( "deployment-a", record.scope_id, "thread-a", bound.run_owner_id ).primary_thread_id == "thread-a" ) with pytest.raises(ScopeConflictError): registry.reserve_turn( "deployment-a", record.scope_id, first.turn_id, "another-request", ) def test_run_reservations_allow_resume_within_one_logical_turn(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") record = registry.provision("deployment-a", "thread-a") turn_id = "00000000-0000-4000-8000-000000000001" initial_request_id = "00000000-0000-4000-8000-000000000002" resume_request_id = "00000000-0000-4000-8000-000000000003" initial = registry.reserve_run( "deployment-a", record.scope_id, initial_request_id, turn_id, "initial" ) retry = registry.reserve_run( "deployment-a", record.scope_id, initial_request_id, turn_id, "initial" ) resume = registry.reserve_run( "deployment-a", record.scope_id, resume_request_id, turn_id, "resume", interrupt_key="interrupt-a", ) assert retry.run_owner_id == initial.run_owner_id assert resume.turn_id == initial.turn_id == turn_id assert resume.run_owner_id != initial.run_owner_id assert ( registry.bind_run( "deployment-a", record.scope_id, resume_request_id, "resume-run" ).run_id == "resume-run" ) with pytest.raises(ScopeConflictError, match="run_request_id"): registry.reserve_run( "deployment-a", record.scope_id, initial_request_id, turn_id, "changed" ) with pytest.raises(ScopeConflictError, match="interrupt"): registry.reserve_run( "deployment-a", record.scope_id, "00000000-0000-4000-8000-000000000004", turn_id, "another-resume", interrupt_key="interrupt-a", ) def test_registry_migrates_turn_keyed_reservations_to_run_request_ids(tmp_path): database_path = tmp_path / "scope-registry.sqlite3" scope_id = "00000000-0000-4000-8000-000000000010" turn_id = "00000000-0000-4000-8000-000000000011" with sqlite3.connect(database_path) as connection: connection.executescript( """ CREATE TABLE scopes ( deployment_id TEXT NOT NULL, scope_id TEXT NOT NULL, primary_thread_id TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, PRIMARY KEY (deployment_id, scope_id) ); CREATE TABLE scope_turns ( deployment_id TEXT NOT NULL, scope_id TEXT NOT NULL, turn_id TEXT NOT NULL, request_hash TEXT NOT NULL, run_owner_id TEXT NOT NULL, run_id TEXT, state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (deployment_id, scope_id, turn_id) ); """ ) connection.execute( """ INSERT INTO scopes VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( "deployment-a", scope_id, "thread-a", "active", 1, "created", "updated", None, ), ) connection.execute( """ INSERT INTO scope_turns VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "deployment-a", scope_id, turn_id, "old-hash", "owner-a", "run-a", "active", "created", "updated", ), ) connection.execute("PRAGMA user_version = 1") ScopeRegistry(database_path).initialize() with sqlite3.connect(database_path) as connection: version = connection.execute("PRAGMA user_version").fetchone()[0] row = connection.execute( """ SELECT run_request_id, turn_id, run_id FROM scope_run_requests WHERE deployment_id = ? AND scope_id = ? """, ("deployment-a", scope_id), ).fetchone() assert version == 2 assert row == (turn_id, turn_id, "run-a") def test_required_runtime_never_falls_back_to_shared_root(tmp_path, monkeypatch): monkeypatch.setattr(paths, "WORKSPACE_ROOT", tmp_path) monkeypatch.setenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "required") import EvoScientist.scope_registry as registry_module registry_module._registry_cache.clear() record = provision_conversation_scope("thread-a", deployment_id="deployment-a") runtime = SimpleNamespace( config={ "configurable": { "thread_id": "thread-a", "workspace_scope_id": record.scope_id, "workspace_scope_owner_id": record.primary_owner_id, "workspace_scope_revision": record.revision, "workspace_deployment_id": "deployment-a", } } ) context = require_scoped_runtime(runtime) assert context is not None assert ( context.files_dir == tmp_path / ".evoscientist" / "conversations" / record.scope_id / "files" ) with pytest.raises(ScopeAccessError): require_scoped_runtime(SimpleNamespace(config={})) async def test_deferred_backend_does_not_block_the_agent_event_loop( tmp_path, monkeypatch ): monkeypatch.setattr(paths, "WORKSPACE_ROOT", tmp_path) monkeypatch.setenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "optional") import EvoScientist.scope_registry as registry_module import EvoScientist.workspace_scope as workspace_scope_module registry_module._registry_cache.clear() record = provision_conversation_scope("thread-a", deployment_id="deployment-a") from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( { "configurable": { "thread_id": "thread-a", "workspace_scope_id": record.scope_id, "workspace_scope_owner_id": record.primary_owner_id, "workspace_scope_revision": record.revision, "workspace_deployment_id": "deployment-a", } } ) blocker = BlockBuster(scanned_modules=[workspace_scope_module, registry_module]) blocker.activate() try: backend = create_workspace_backend( SimpleNamespace(), legacy_backend=lambda: pytest.fail("scoped runtime used legacy backend"), ) # The filesystem operation is dispatched to a thread, which does not # inherit LangGraph's context variable. var_child_runnable_config.reset(token) token = None result = await backend.als("/") finally: if token is not None: var_child_runnable_config.reset(token) blocker.deactivate() assert isinstance(backend, DeferredScopedBackend) assert result.error is None def test_deferred_backend_revalidates_a_deleting_scope(tmp_path, monkeypatch): monkeypatch.setattr(paths, "WORKSPACE_ROOT", tmp_path) monkeypatch.setenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "optional") import EvoScientist.scope_registry as registry_module registry_module._registry_cache.clear() record = provision_conversation_scope("thread-a", deployment_id="deployment-a") registry = registry_module.get_scope_registry(tmp_path) active = registry.transition_scope( "deployment-a", record.scope_id, expected_revision=record.revision, state="active", ) runtime = SimpleNamespace( config={ "configurable": { "thread_id": "thread-a", "workspace_scope_id": active.scope_id, "workspace_scope_owner_id": active.primary_owner_id, "workspace_scope_revision": active.revision, "workspace_deployment_id": "deployment-a", } } ) backend = create_workspace_backend( runtime, legacy_backend=lambda: pytest.fail("scoped runtime used legacy backend"), ) assert backend.ls("/").error is None registry.transition_scope( "deployment-a", active.scope_id, expected_revision=active.revision, state="deleting", ) with pytest.raises(ScopeAccessError): backend.ls("/") def test_required_executor_needs_an_oci_runtime(monkeypatch): import EvoScientist.workspace_scope as scope_module monkeypatch.setattr(scope_module.shutil, "which", lambda _: None) with pytest.raises(RuntimeError, match="OCI runtime"): verify_required_executor() def test_active_lock_can_only_be_renewed_by_the_current_owner(tmp_path): registry = ScopeRegistry(tmp_path / "scope-registry.sqlite3") operation_id = "00000000-0000-4000-8000-000000000001" lock = registry.acquire_lock( "deployment-a", "workspace-cutover", operation_id, lease_seconds=30 ) assert registry.active_lock("deployment-a", "workspace-cutover") == lock renewed = registry.renew_lock( "deployment-a", "workspace-cutover", operation_id, lease_seconds=60 ) assert renewed.expires_at > lock.expires_at registry.release_lock("deployment-a", "workspace-cutover", operation_id) assert registry.active_lock("deployment-a", "workspace-cutover") is None def test_cutover_lock_blocks_scoped_runtime_and_owner_creation(tmp_path, monkeypatch): monkeypatch.setattr(paths, "WORKSPACE_ROOT", tmp_path) monkeypatch.setenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "required") import EvoScientist.scope_registry as registry_module registry_module._registry_cache.clear() record = provision_conversation_scope("thread-a", deployment_id="deployment-a") registry = registry_module.get_scope_registry(tmp_path) registry.acquire_lock( "deployment-a", "workspace-cutover", "00000000-0000-4000-8000-000000000001", ) with pytest.raises(ScopeAccessError, match="cutover"): registry.provision("deployment-a", "new-thread") runtime = SimpleNamespace( config={ "configurable": { "thread_id": "thread-a", "workspace_scope_id": record.scope_id, "workspace_scope_owner_id": record.primary_owner_id, "workspace_deployment_id": "deployment-a", } } ) with pytest.raises(ScopeAccessError, match="cutover"): require_scoped_runtime(runtime) with pytest.raises(ScopeAccessError, match="cutover"): registry.register_owner( "deployment-a", record.scope_id, owner_type="derived_run", parent_owner_id=record.primary_owner_id, )