"""Lifecycle maintenance tests for scoped conversation workspaces.""" from __future__ import annotations import os import sqlite3 from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from EvoScientist.scope_registry import deployment_id_for_workspace, get_scope_registry from EvoScientist.workspace_maintenance import run_workspace_maintenance from EvoScientist.workspace_scope import provision_conversation_scope class _Threads: def __init__(self) -> None: self.deleted: list[str] = [] def delete(self, thread_id: str) -> None: self.deleted.append(thread_id) def get_state(self, thread_id: str) -> dict: return {"values": {"messages": []}} class _Runs: def list(self, *, thread_id: str, limit: int): assert limit == 1000 return [] def _age_scope(workspace_root: Path, scope_id: str, when: datetime) -> None: registry = get_scope_registry(workspace_root) with sqlite3.connect(registry.path) as database: database.execute( "UPDATE scopes SET created_at = ?, updated_at = ? WHERE scope_id = ?", (when.isoformat(), when.isoformat(), scope_id), ) def test_maintenance_deletes_stale_draft_and_purges_old_trash(tmp_path, monkeypatch): import EvoScientist.scope_registry as registry_module registry_module._registry_cache.clear() monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False) scope = provision_conversation_scope("draft-thread", workspace_root=tmp_path) _age_scope(tmp_path, scope.scope_id, datetime(2026, 7, 1, tzinfo=UTC)) files = tmp_path / ".evoscientist" / "conversations" / scope.scope_id / "files" files.mkdir(parents=True, exist_ok=True) old_trash = tmp_path / ".evoscientist" / "trash" / "old-scope" old_trash.mkdir(parents=True) old_timestamp = datetime(2026, 7, 1, tzinfo=UTC).timestamp() os.utime(old_trash, (old_timestamp, old_timestamp)) threads = _Threads() report = run_workspace_maintenance( workspace_root=tmp_path, client=SimpleNamespace(threads=threads, runs=_Runs()), now=datetime(2026, 7, 10, tzinfo=UTC), ) assert report["errors"] == [] assert report["deleted_drafts"] == ["draft-thread"] assert report["purged_trash"] == 1 assert threads.deleted == ["draft-thread"] registry = get_scope_registry(tmp_path) deployment_id = deployment_id_for_workspace(tmp_path) assert registry.get(deployment_id, scope.scope_id).state == "deleted" assert not old_trash.exists() assert any((tmp_path / ".evoscientist" / "trash").iterdir()) def test_maintenance_leaves_draft_with_non_primary_owner(tmp_path, monkeypatch): import EvoScientist.scope_registry as registry_module registry_module._registry_cache.clear() monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False) scope = provision_conversation_scope("draft-thread", workspace_root=tmp_path) _age_scope(tmp_path, scope.scope_id, datetime.now(UTC) - timedelta(days=2)) registry = get_scope_registry(tmp_path) registry.register_owner( deployment_id_for_workspace(tmp_path), scope.scope_id, owner_type="unknown-legacy-owner", state="active", ) report = run_workspace_maintenance( workspace_root=tmp_path, client=SimpleNamespace(threads=_Threads(), runs=_Runs()), ) assert report["deleted_drafts"] == [] assert report["skipped_drafts"] == ["draft-thread"] def test_maintenance_leaves_draft_that_has_messages(tmp_path, monkeypatch): import EvoScientist.scope_registry as registry_module class _ThreadWithMessages(_Threads): def get_state(self, thread_id: str) -> dict: return {"values": {"messages": [{"content": "keep this draft"}]}} registry_module._registry_cache.clear() monkeypatch.delenv("EVOSCIENTIST_DEPLOYMENT_ID", raising=False) scope = provision_conversation_scope("draft-thread", workspace_root=tmp_path) _age_scope(tmp_path, scope.scope_id, datetime.now(UTC) - timedelta(days=2)) report = run_workspace_maintenance( workspace_root=tmp_path, client=SimpleNamespace(threads=_ThreadWithMessages(), runs=_Runs()), ) assert report["deleted_drafts"] == [] assert report["skipped_drafts"] == ["draft-thread"]