Files
EvoScientist/tests/test_workspace_scope.py

449 lines
15 KiB
Python

"""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,
)