449 lines
15 KiB
Python
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,
|
|
)
|