224 lines
7.3 KiB
Python
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"
|