Merge adapter lifecycle safeguards
This commit is contained in:
commit
15a913003c
11 changed files with 541 additions and 11 deletions
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
|
|
@ -14,6 +16,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|||
from docforge.adapter_contract import (
|
||||
AdapterAssembly,
|
||||
AdapterEdge,
|
||||
AdapterImplementation,
|
||||
AdapterManifest,
|
||||
AdapterNode,
|
||||
AdapterProject,
|
||||
|
|
@ -346,6 +349,146 @@ class AdapterContractTests(unittest.TestCase):
|
|||
with self.assertRaisesRegex(DocForgeError, "confined"):
|
||||
AdapterProject(Loader(self.projection(root)), cache_root=outside)
|
||||
|
||||
def test_adapter_implementation_changes_require_a_process_restart(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
implementation_root = root / "adapter"
|
||||
implementation_root.mkdir()
|
||||
implementation = implementation_root / "loader.py"
|
||||
implementation.write_text("VERSION = 1\n", encoding="utf-8")
|
||||
ignored = implementation_root / "loader.pyc"
|
||||
ignored.write_bytes(b"derived")
|
||||
project = AdapterProject(
|
||||
Loader(self.projection(root)),
|
||||
cache_root=root / ".cache" / "shadow",
|
||||
settings=AdapterProjectSettings(
|
||||
implementation=AdapterImplementation(
|
||||
roots=(implementation_root,),
|
||||
suffixes=(".py",),
|
||||
)
|
||||
),
|
||||
)
|
||||
ProjectIndex(project).build()
|
||||
|
||||
ignored.write_bytes(b"changed derived state")
|
||||
project.validate_runtime()
|
||||
implementation.write_text("VERSION = 2\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(DocForgeError) as captured:
|
||||
project.validate_runtime()
|
||||
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||
self.assertEqual(["adapter/loader.py"], captured.exception.details["changed"])
|
||||
self.assertEqual([], captured.exception.details["added"])
|
||||
self.assertEqual([], captured.exception.details["deleted"])
|
||||
self.assertEqual(1, captured.exception.details["changed_count"])
|
||||
self.assertFalse(captured.exception.details["paths_truncated"])
|
||||
|
||||
def test_adapter_implementation_additions_and_deletions_require_a_restart(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
implementation_root = root / "adapter"
|
||||
implementation_root.mkdir()
|
||||
original = implementation_root / "loader.py"
|
||||
original.write_text("VERSION = 1\n", encoding="utf-8")
|
||||
project = AdapterProject(
|
||||
Loader(self.projection(root)),
|
||||
cache_root=root / ".cache" / "shadow",
|
||||
settings=AdapterProjectSettings(
|
||||
implementation=AdapterImplementation(
|
||||
roots=(implementation_root,),
|
||||
suffixes=(".py",),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
added = implementation_root / "helpers.py"
|
||||
added.write_text("VALUE = 1\n", encoding="utf-8")
|
||||
with self.assertRaises(DocForgeError) as addition:
|
||||
project.load()
|
||||
self.assertEqual("adapter_restart_required", addition.exception.code)
|
||||
self.assertEqual(["adapter/helpers.py"], addition.exception.details["added"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
implementation_root = root / "adapter"
|
||||
implementation_root.mkdir()
|
||||
original = implementation_root / "loader.py"
|
||||
original.write_text("VERSION = 1\n", encoding="utf-8")
|
||||
project = AdapterProject(
|
||||
Loader(self.projection(root)),
|
||||
cache_root=root / ".cache" / "shadow",
|
||||
settings=AdapterProjectSettings(
|
||||
implementation=AdapterImplementation(
|
||||
roots=(implementation_root,),
|
||||
suffixes=(".py",),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
original.unlink()
|
||||
with self.assertRaises(DocForgeError) as deletion:
|
||||
project.incremental_state()
|
||||
self.assertEqual("adapter_restart_required", deletion.exception.code)
|
||||
self.assertEqual(["adapter/loader.py"], deletion.exception.details["deleted"])
|
||||
|
||||
def test_adapter_implementation_is_inferred_from_a_project_local_package(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
package = root / "adapter_fixture_dynamic"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "loader.py").write_text(
|
||||
"class Loader:\n"
|
||||
" def __init__(self, projection):\n"
|
||||
" self.projection = projection\n"
|
||||
" def load_projection(self):\n"
|
||||
" return self.projection\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
sys.path.insert(0, str(root))
|
||||
try:
|
||||
module = importlib.import_module("adapter_fixture_dynamic.loader")
|
||||
project = AdapterProject(
|
||||
module.Loader(self.projection(root)),
|
||||
cache_root=root / ".cache" / "shadow",
|
||||
)
|
||||
(package / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
with self.assertRaises(DocForgeError) as captured:
|
||||
project.validate_runtime()
|
||||
finally:
|
||||
sys.path.remove(str(root))
|
||||
sys.modules.pop("adapter_fixture_dynamic.loader", None)
|
||||
sys.modules.pop("adapter_fixture_dynamic", None)
|
||||
|
||||
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||
self.assertEqual(
|
||||
["adapter_fixture_dynamic/helper.py"],
|
||||
captured.exception.details["added"],
|
||||
)
|
||||
|
||||
def test_adapter_descriptor_changes_require_a_restart(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
descriptor_root = root / ".docforge"
|
||||
descriptor_root.mkdir()
|
||||
descriptor = descriptor_root / "project.toml"
|
||||
descriptor.write_text("adapter_version = 1\n", encoding="utf-8")
|
||||
project = AdapterProject(
|
||||
Loader(self.projection(root)),
|
||||
cache_root=root / ".cache" / "shadow",
|
||||
settings=AdapterProjectSettings(descriptor_path=descriptor),
|
||||
)
|
||||
|
||||
descriptor.write_text("adapter_version = 2\n", encoding="utf-8")
|
||||
with self.assertRaises(DocForgeError) as captured:
|
||||
project.validate_runtime()
|
||||
|
||||
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||
self.assertEqual(
|
||||
[".docforge/project.toml"],
|
||||
captured.exception.details["changed"],
|
||||
)
|
||||
|
||||
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
|
|
@ -547,6 +690,45 @@ class AdapterContractTests(unittest.TestCase):
|
|||
|
||||
|
||||
class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_mcp_reports_adapter_restart_remediation_without_synchronizing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
implementation_root = root / "adapter"
|
||||
implementation_root.mkdir()
|
||||
implementation = implementation_root / "loader.py"
|
||||
implementation.write_text("VERSION = 1\n", encoding="utf-8")
|
||||
fixture = AdapterContractTests()
|
||||
project = AdapterProject(
|
||||
Loader(fixture.projection(root)),
|
||||
cache_root=root / ".cache" / "adapter-read-only",
|
||||
settings=AdapterProjectSettings(
|
||||
implementation=AdapterImplementation(
|
||||
roots=(implementation_root,),
|
||||
suffixes=(".py",),
|
||||
)
|
||||
),
|
||||
)
|
||||
ProjectIndex(project).build()
|
||||
server = create_read_only_server(project)
|
||||
implementation.write_text("VERSION = 2\n", encoding="utf-8")
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
server, raise_exceptions=True
|
||||
) as session:
|
||||
result = await session.call_tool("docforge_project_info", {})
|
||||
|
||||
self.assertEqual("error", result.structuredContent["status"])
|
||||
self.assertEqual(
|
||||
"adapter_restart_required",
|
||||
result.structuredContent["error"]["code"],
|
||||
)
|
||||
self.assertEqual("stale", result.structuredContent["staleness"])
|
||||
self.assertEqual(
|
||||
{"retryable": False, "action": "restart_project_server"},
|
||||
result.structuredContent["error"]["remediation"],
|
||||
)
|
||||
self.assertNotIn("synchronization", result.structuredContent)
|
||||
|
||||
async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue