feat: apply source registry changes

This commit is contained in:
lda
2026-06-05 16:07:57 +07:00 Verified
parent b632073e97
commit c12609b112
17 changed files with 408 additions and 10 deletions
@@ -361,3 +361,42 @@ def test_api_with_mutation_satisfies_surface_protocol() -> None:
api, _ = _mutation_api(entries=[FakeRegistryEntry(id="x")])
surface: WorkflowSourceRegistrySurface = api
assert surface is not None
class RecordingApplyProvider:
def __init__(self) -> None:
self.called = False
def apply_registry_changes(self) -> dict[str, object]:
self.called = True
return {
"applied": True,
"registered": ["demo.new"],
"updated": [],
"removed": [],
"connection_count": 1,
"registry_entry_count": 1,
}
async def test_apply_registry_changes_delegates_to_apply_provider() -> None:
read_provider = FakeRegistryProvider([])
apply_provider = RecordingApplyProvider()
api = WorkflowSourceRegistryApi(
provider=read_provider,
apply_provider=apply_provider,
)
payload = await api.apply_registry_changes()
assert apply_provider.called is True
assert payload["applied"] is True
assert payload["registered"] == ["demo.new"]
assert payload["connection_count"] == 1
async def test_apply_registry_changes_requires_apply_provider() -> None:
api = WorkflowSourceRegistryApi(provider=FakeRegistryProvider([]))
with pytest.raises(TypeError, match="apply_registry_changes requires"):
await api.apply_registry_changes()
+32
View File
@@ -66,6 +66,7 @@ def test_wf_admin_registry_help_exists() -> None:
assert "enable" in result.output
assert "disable" in result.output
assert "remove" in result.output
assert "apply" in result.output
def test_wf_admin_registry_list_help_exists() -> None:
@@ -504,3 +505,34 @@ def test_read_json_arg_invalid_file(tmp_path: Path) -> None:
def test_read_json_arg_missing_file() -> None:
with pytest.raises(Exception, match="file not found"):
_read_json_arg(None, "/nonexistent/file.json", "--input/--input-file")
# --- apply command tests ---
def test_wf_admin_registry_apply_help_exists() -> None:
result = runner.invoke(app, ["admin", "registry", "apply", "--help"])
assert result.exit_code == 0
def test_registry_apply_calls_surface(monkeypatch: pytest.MonkeyPatch) -> None:
surface = MagicMock()
surface.apply_registry_changes.return_value = {
"applied": True,
"registered": ["demo.new"],
"updated": [],
"removed": [],
"connection_count": 1,
"registry_entry_count": 1,
}
fake_ctx = _fake_context_with_admin(surface)
_patch_load_cli_context(monkeypatch, fake_ctx)
_patch_asyncio_run(monkeypatch)
result = runner.invoke(app, ["admin", "registry", "apply"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["applied"] is True
assert payload["registered"] == ["demo.new"]
@@ -247,3 +247,92 @@ def test_remove_missing_source_raises_key_error(tmp_path: Path) -> None:
with pytest.raises(KeyError, match="unknown registry source"):
provider.remove_registry_entry("no.such.id")
# -- apply tests -----------------------------------------------------------
def _apply_provider(
tmp_path: Path,
*,
config_connections=(),
registry_sources=(),
):
from wf_mcp.broker.service.connection_service import ConnectionService
from wf_mcp.broker.service.events import BrokerEventRecorder
from wf_mcp.broker.service.source_catalog import SourceCatalogService
from wf_mcp.events import EventBus
from wf_mcp.models import BrokerConfig
from wf_mcp.runtime import ToolExecutor
from wf_mcp.source_registry import FileSourceRegistryStore, SourceRegistryFile
from wf_mcp.storage import FileStore
def _tool_executor_for(_connection: ConnectionConfig) -> ToolExecutor:
raise AssertionError("tool executor should not be needed in these tests")
events = BrokerEventRecorder(EventBus())
connection_service = ConnectionService(events=events)
source_catalog = SourceCatalogService(
store=FileStore(tmp_path),
connection_lookup=connection_service.get,
connection_list_enabled=connection_service.list_enabled,
connection_list_all=connection_service.list_all,
tool_executor_for=_tool_executor_for,
load_auth=lambda connection_id: None,
emit_event=events.record_event,
)
connection_service.bind_source_catalog(source_catalog)
store = FileSourceRegistryStore(tmp_path / "reg")
store.save_registry(SourceRegistryFile(sources=list(registry_sources)))
config = BrokerConfig(store_root=tmp_path, connections=list(config_connections))
provider = SourceRegistryAdminProvider(
source_registry_store=store,
config_connections=config.connections,
connection_service=connection_service,
config=config,
ensure_adapter=lambda connection: None,
)
return provider, connection_service, source_catalog
def test_source_registry_apply_materializes_registry_connection(tmp_path: Path) -> None:
entry = _entry("dynamic.default", provider="dynamic", account="default")
provider, connection_service, source_catalog = _apply_provider(
tmp_path,
registry_sources=[entry],
)
payload = provider.apply_registry_changes()
assert payload["applied"] is True
assert payload["registered"] == ["dynamic.default"]
assert payload["updated"] == []
assert payload["removed"] == []
assert payload["connection_count"] == 1
assert payload["registry_entry_count"] == 1
assert connection_service.get("dynamic.default").server == "dynamic"
assert source_catalog.capability_sources["dynamic.default"].enabled is True
def test_source_registry_apply_removes_deleted_registry_connection(tmp_path: Path) -> None:
entry = _entry("dynamic.default", provider="dynamic", account="default")
provider, connection_service, source_catalog = _apply_provider(
tmp_path,
registry_sources=[entry],
)
provider.apply_registry_changes()
provider.remove_registry_entry("dynamic.default")
payload = provider.apply_registry_changes()
assert payload["removed"] == ["dynamic.default"]
assert "dynamic.default" not in connection_service.connections.connections
assert "dynamic.default" not in source_catalog.capability_sources
def test_source_registry_apply_requires_runtime_context(tmp_path: Path) -> None:
store = _store_with_entries(tmp_path / "reg")
provider = SourceRegistryAdminProvider(source_registry_store=store)
with pytest.raises(RuntimeError, match="requires runtime service context"):
provider.apply_registry_changes()
@@ -103,13 +103,59 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
connections = await _rpc(
http_client, "workflow.admin.connections.list", {}
)
events = await _rpc(http_client, "workflow.admin.events.list", {})
assert connections["result"]["connections"][0]["id"] == "demo.default"
assert any(
event["kind"] == "connection_registered"
for event in events["result"]["events"]
)
async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
server = build_workflow_server_from_config(config)
app = create_rpc_app(server)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test",
) as client:
await _rpc(
client,
"workflow.admin.source_registry.add",
{
"entry": {
"kind": "mcp",
"id": "dynamic.default",
"enabled": True,
"provider": "dynamic",
"account": "default",
"transport": {
"kind": "stdio",
"command": "dynamic-server",
"args": [],
"env": {},
},
}
},
)
before = await _rpc(
client,
"workflow.sources.list",
{"limit": 50},
)
applied = await _rpc(
client,
"workflow.admin.source_registry.apply",
{},
)
after = await _rpc(
client,
"workflow.sources.list",
{"limit": 50},
)
before_ids = {source["id"] for source in before["result"]["sources"]}
after_ids = {source["id"] for source in after["result"]["sources"]}
assert "dynamic.default" not in before_ids
assert applied["result"]["registered"] == ["dynamic.default"]
assert "dynamic.default" in after_ids
async def test_mcp_backed_rpc_can_be_built_from_neutral_workflow_config(
@@ -8,6 +8,7 @@ import httpx
from wf_api import WorkflowSourceRegistryApi
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
from wf_transport_rpc_http.client_source_registry import RpcSourceRegistryClientMixin
@dataclass(frozen=True, slots=True)
@@ -472,3 +473,67 @@ async def test_rpc_client_source_registry_mutation_methods_exist() -> None:
"workflow.admin.source_registry.remove",
{"source_id": "s"},
)
# --- apply tests ---
async def test_rpc_source_registry_apply_unavailable_on_local_static(tmp_path) -> None:
app = create_rpc_app(build_local_static_workflow_server(tmp_path / "store"))
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test",
) as client:
payload = await _rpc(
client,
"workflow.admin.source_registry.apply",
{},
)
assert payload["error"]["data"]["code"] == "source_registry_unavailable"
async def test_rpc_source_registry_apply_returns_summary(tmp_path) -> None:
from unittest.mock import AsyncMock
admin = AsyncMock()
admin.apply_registry_changes.return_value = {
"applied": True,
"registered": ["demo.new"],
"updated": [],
"removed": [],
"connection_count": 1,
"registry_entry_count": 1,
}
server = replace(
build_local_static_workflow_server(tmp_path / "store"),
source_registry_admin=admin,
)
app = create_rpc_app(server)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test",
) as client:
payload = await _rpc(
client,
"workflow.admin.source_registry.apply",
{},
)
assert payload["result"]["applied"] is True
assert payload["result"]["registered"] == ["demo.new"]
admin.apply_registry_changes.assert_awaited_once()
async def test_rpc_client_source_registry_apply_method_exists() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
class Client(RpcSourceRegistryClientMixin):
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
calls.append((method, params))
return {"applied": True}
payload = await Client().apply_registry_changes()
assert payload["applied"] is True
assert calls == [("workflow.admin.source_registry.apply", {})]