Files
EvoScientist/tests/test_workspace_cutover.py
T

224 lines
7.3 KiB
Python

"""Tests for the required-mode migration gate."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from EvoScientist.scope_registry import deployment_id_for_workspace, get_scope_registry
from EvoScientist.workspace_cutover import (
run_workspace_cutover,
verify_required_cutover,
)
class _Threads:
def __init__(self, threads: list[dict] | None = None) -> None:
self.updated: list[tuple[str, dict]] = []
self._threads = threads or [
{
"thread_id": "primary-thread",
"metadata": {"graph_id": "EvoScientist"},
}
]
def search(self, *, limit: int, offset: int = 0):
assert limit == 100
assert offset == 0
return self._threads
def update(self, thread_id: str, *, metadata: dict):
self.updated.append((thread_id, metadata))
class _Crons:
def __init__(self, crons: list[dict] | None = None) -> None:
self._crons = crons or []
self.updated: list[tuple[str, bool]] = []
def search(self, *, limit: int):
assert limit == 1000
return self._crons
def update(self, cron_id: str, *, enabled: bool):
self.updated.append((cron_id, enabled))
class _Runs:
def __init__(self, runs: dict[str, list[dict]] | None = None) -> None:
self.runs = runs or {}
self.cancelled: list[tuple[str, str]] = []
def list(self, *, thread_id: str, limit: int):
assert limit == 1000
return self.runs.get(thread_id, [])
def cancel(self, thread_id: str, run_id: str, *, wait: bool, action: str):
assert wait is True
assert action == "interrupt"
self.cancelled.append((thread_id, run_id))
for run in self.runs.get(thread_id, []):
if run["run_id"] == run_id:
run["status"] = "interrupted"
def test_cutover_writes_a_verified_passing_report(tmp_path, monkeypatch):
import EvoScientist.scope_registry as registry_module
registry_module._registry_cache.clear()
monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False)
client = SimpleNamespace(threads=_Threads(), crons=_Crons(), runs=_Runs())
report = run_workspace_cutover(
workspace_root=tmp_path, client=client, browser_sdk_gate=True
)
assert report["status"] == "passed"
assert client.threads.updated[0][1]["workspace_scope_id"]
verify_required_cutover(tmp_path)
def test_required_cutover_rejects_missing_report(tmp_path):
with pytest.raises(RuntimeError, match="workspace-cutover"):
verify_required_cutover(tmp_path)
def test_cutover_quarantines_unscoped_derived_threads(tmp_path, monkeypatch):
import EvoScientist.scope_registry as registry_module
registry_module._registry_cache.clear()
monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False)
threads = _Threads(
[
{"thread_id": "primary-thread", "metadata": {"graph_id": "EvoScientist"}},
{"thread_id": "legacy-child", "metadata": {"agent_name": "worker"}},
]
)
runs = _Runs({"legacy-child": [{"run_id": "run-child", "status": "running"}]})
report = run_workspace_cutover(
workspace_root=tmp_path,
client=SimpleNamespace(threads=threads, crons=_Crons(), runs=runs),
browser_sdk_gate=True,
)
assert report["status"] == "passed"
assert runs.cancelled == [("legacy-child", "run-child")]
assert report["quarantined_derived_threads"] == [
{
"thread_id": "legacy-child",
"cancelled_run_ids": ["run-child"],
"reason": "unscoped-derived-thread",
}
]
derived_update = next(
update for update in threads.updated if update[0] == "legacy-child"
)
assert (
derived_update[1]["workspace_quarantine"]["reason"] == "unscoped-derived-thread"
)
def test_cutover_accepts_registered_scoped_derived_thread(tmp_path, monkeypatch):
import EvoScientist.scope_registry as registry_module
registry_module._registry_cache.clear()
monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False)
deployment_id = deployment_id_for_workspace(tmp_path)
registry = get_scope_registry(tmp_path)
scope = registry.provision(deployment_id, "primary-thread")
owner = registry.register_owner(
deployment_id,
scope.scope_id,
owner_type="async_thread",
resource_id="registered-child",
parent_owner_id=scope.primary_owner_id,
state="active",
)
threads = _Threads(
[
{"thread_id": "primary-thread", "metadata": {"graph_id": "EvoScientist"}},
{
"thread_id": "registered-child",
"metadata": {
"workspace_scope_id": scope.scope_id,
"workspace_scope_owner_id": owner.owner_id,
"workspace_deployment_id": deployment_id,
},
},
]
)
report = run_workspace_cutover(
workspace_root=tmp_path,
client=SimpleNamespace(threads=threads, crons=_Crons(), runs=_Runs()),
browser_sdk_gate=True,
)
assert report["status"] == "passed"
assert report["validated_derived_threads"] == ["registered-child"]
assert report["quarantined_derived_threads"] == []
def test_cutover_quarantines_scoped_thread_without_registry_owner(
tmp_path, monkeypatch
):
import EvoScientist.scope_registry as registry_module
registry_module._registry_cache.clear()
monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False)
threads = _Threads(
[
{"thread_id": "primary-thread", "metadata": {"graph_id": "EvoScientist"}},
{
"thread_id": "forged-child",
"metadata": {
"workspace_scope_id": "00000000-0000-4000-8000-000000000001",
"workspace_scope_owner_id": "00000000-0000-4000-8000-000000000002",
},
},
]
)
report = run_workspace_cutover(
workspace_root=tmp_path,
client=SimpleNamespace(threads=threads, crons=_Crons(), runs=_Runs()),
browser_sdk_gate=True,
)
assert report["status"] == "passed"
assert report["invalid_scoped_derived_threads"][0]["thread_id"] == "forged-child"
update = next(item for item in threads.updated if item[0] == "forged-child")
assert "workspace_scope_id" not in update[1]
assert (
update[1]["workspace_quarantine"]["reason"] == "invalid-scoped-derived-thread"
)
def test_cutover_disables_scoped_cron_without_registry_owner(tmp_path, monkeypatch):
import EvoScientist.scope_registry as registry_module
registry_module._registry_cache.clear()
monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False)
crons = _Crons(
[
{
"cron_id": "forged-cron",
"metadata": {
"run_kind": "scheduled_task",
"workspace_scope_id": "00000000-0000-4000-8000-000000000001",
},
}
]
)
report = run_workspace_cutover(
workspace_root=tmp_path,
client=SimpleNamespace(threads=_Threads(), crons=crons, runs=_Runs()),
browser_sdk_gate=True,
)
assert report["status"] == "passed"
assert crons.updated == [("forged-cron", False)]
assert report["invalid_scoped_crons"][0]["cron_id"] == "forged-cron"