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
+8 -3
View File
@@ -209,9 +209,14 @@ implementation state.
- Manual product smoke: run `wf-rpc-server --mcp-config ...`, point - Manual product smoke: run `wf-rpc-server --mcp-config ...`, point
`wf --url ...` at it, and capture real CLI/server UX gaps before adding `wf --url ...` at it, and capture real CLI/server UX gaps before adding
more architecture. more architecture.
- Source registry apply/reload: decide and implement how persisted registry - Source registry apply/reload: decide and implement how persisted registry
mutations affect the running source catalog. Prefer an explicit mutations affect the running source catalog. Prefer an explicit
apply/reload operation before automatic live remount. apply/reload operation before automatic live remount.
- Completed: desired source registry mutations can now be applied explicitly
through `wf admin registry apply` / `workflow.admin.source_registry.apply`.
V1 apply reconciles registry state into the current server connection/source
graph; it does not auto-apply mutations, mutate config files, or remount
MCP proxy providers.
- Persisted resume across server restart: prove an interrupted run can be - Persisted resume across server restart: prove an interrupted run can be
resumed after rebuilding the MCP-backed RPC server from the same stores and resumed after rebuilding the MCP-backed RPC server from the same stores and
pinned source environment. pinned source environment.
@@ -312,6 +312,17 @@ Status: complete. MCP broker config connections now support
`seed` materializes missing store entries and lets existing registry entries `seed` materializes missing store entries and lets existing registry entries
own future runtime state. own future runtime state.
### Apply Semantics
Registry mutation commands write desired persisted state. They do not implicitly
change the running server. `apply_registry_changes` is the explicit boundary
that reconciles desired registry state with the current runtime source graph.
The apply operation mirrors config reload reconciliation by calling the same
connection/source merge logic. It preserves `locked` config shadowing and `seed`
config handoff rules. It does not mutate config files, remount public MCP proxy
providers, or handle upstream credential prompts.
## Open Questions ## Open Questions
- Should dynamic registry entries support non-MCP transports in v1, or only MCP - Should dynamic registry entries support non-MCP transports in v1, or only MCP
+9
View File
@@ -74,6 +74,15 @@ combined with `--mcp-config`.
workflow artifacts and deployments, so it can be empty even when the server has workflow artifacts and deployments, so it can be empty even when the server has
runtime sources and saved workflows. runtime sources and saved workflows.
After `wf admin registry add/update/enable/disable/remove`, call:
```bash
wf --url http://127.0.0.1:8765/rpc admin registry apply
```
Apply updates the running server's source graph from desired registry state.
It is explicit in v1; registry mutations are not auto-applied.
## Output Policy ## Output Policy
JSON is the default output format for every command. JSON is the default output format for every command.
+2
View File
@@ -26,6 +26,7 @@ from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi from .source_admin import WorkflowSourceAdminApi
from .source_registry_admin import ( from .source_registry_admin import (
WorkflowSourceRegistryApi, WorkflowSourceRegistryApi,
WorkflowSourceRegistryApplyProvider,
WorkflowSourceRegistryMutationProvider, WorkflowSourceRegistryMutationProvider,
WorkflowSourceRegistryProvider, WorkflowSourceRegistryProvider,
) )
@@ -108,6 +109,7 @@ __all__ = [
"WorkflowSourceAdminApi", "WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistryApi", "WorkflowSourceRegistryApi",
"WorkflowSourceRegistryApplyProvider",
"WorkflowSourceRegistryMutationProvider", "WorkflowSourceRegistryMutationProvider",
"WorkflowSourceRegistryProvider", "WorkflowSourceRegistryProvider",
"WorkflowSourceRegistrySurface", "WorkflowSourceRegistrySurface",
+14
View File
@@ -33,6 +33,13 @@ class WorkflowSourceRegistryMutationProvider(Protocol):
def remove_registry_entry(self, source_id: str) -> Mapping[str, Any] | object: ... def remove_registry_entry(self, source_id: str) -> Mapping[str, Any] | object: ...
@runtime_checkable
class WorkflowSourceRegistryApplyProvider(Protocol):
"""Applies desired registry state to the currently running server."""
def apply_registry_changes(self) -> Mapping[str, Any] | object: ...
class WorkflowSourceRegistryApi: class WorkflowSourceRegistryApi:
"""Protocol-neutral desired source registry operations. """Protocol-neutral desired source registry operations.
@@ -47,9 +54,11 @@ class WorkflowSourceRegistryApi:
*, *,
provider: WorkflowSourceRegistryProvider, provider: WorkflowSourceRegistryProvider,
mutation_provider: WorkflowSourceRegistryMutationProvider | None = None, mutation_provider: WorkflowSourceRegistryMutationProvider | None = None,
apply_provider: WorkflowSourceRegistryApplyProvider | None = None,
) -> None: ) -> None:
self._provider = provider self._provider = provider
self._mutation_provider = mutation_provider self._mutation_provider = mutation_provider
self._apply_provider = apply_provider
def _is_shadowed(self, source_id: str) -> bool: def _is_shadowed(self, source_id: str) -> bool:
return source_id in self._provider.config_source_ids() return source_id in self._provider.config_source_ids()
@@ -165,6 +174,11 @@ class WorkflowSourceRegistryApi:
"source_id": str(result.get("source_id", source_id)), "source_id": str(result.get("source_id", source_id)),
} }
async def apply_registry_changes(self) -> dict[str, Any]:
if self._apply_provider is None:
raise TypeError("apply_registry_changes requires an apply provider")
return _payload(self._apply_provider.apply_registry_changes())
def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]: def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
"""Normalize provider objects without depending on MCP registry types.""" """Normalize provider objects without depending on MCP registry types."""
+2
View File
@@ -273,6 +273,8 @@ class WorkflowSourceRegistrySurface(Protocol):
source_id: str, source_id: str,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def apply_registry_changes(self) -> dict[str, Any]: ...
__all__ = [ __all__ = [
"WorkflowAdminSurface", "WorkflowAdminSurface",
+9
View File
@@ -144,6 +144,15 @@ def remove_registry_entry(
emit_json(payload) emit_json(payload)
@app.command("apply")
def apply_registry_changes(ctx: typer.Context) -> None:
"""Apply desired registry state to the running server."""
context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context)
payload = asyncio.run(admin.apply_registry_changes())
emit_json(payload)
def _read_json_arg( def _read_json_arg(
inline: str | None, inline: str | None,
file_path: str | None, file_path: str | None,
+9
View File
@@ -22,6 +22,7 @@ from .service.source_registry_admin import SourceRegistryAdminProvider
from .service.workflow_operation_context import context_from_service from .service.workflow_operation_context import context_from_service
from .tools import register_broker_tools from .tools import register_broker_tools
from ..models import BrokerConfig from ..models import BrokerConfig
from ..sdk.adapter import McpSdkAdapter
from ..source_registry import FileSourceRegistryStore, SourceRegistryStore from ..source_registry import FileSourceRegistryStore, SourceRegistryStore
@@ -70,10 +71,18 @@ def workflow_server_from_service(
registry_provider = SourceRegistryAdminProvider( registry_provider = SourceRegistryAdminProvider(
source_registry_store=source_registry_store, source_registry_store=source_registry_store,
config_connections=config.connections, config_connections=config.connections,
connection_service=service.connection_service,
config=config,
ensure_adapter=lambda connection: service.register_adapter(
connection.server, McpSdkAdapter()
)
if connection.server not in service.adapters
else None,
) )
source_registry_admin = WorkflowSourceRegistryApi( source_registry_admin = WorkflowSourceRegistryApi(
provider=registry_provider, provider=registry_provider,
mutation_provider=registry_provider, mutation_provider=registry_provider,
apply_provider=registry_provider,
) )
stores = WorkflowStores( stores = WorkflowStores(
artifact_store=service.artifact_store, artifact_store=service.artifact_store,
@@ -1,17 +1,18 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from wf_api.source_registry_admin import WorkflowSourceRegistryMutationProvider from wf_api.source_registry_admin import WorkflowSourceRegistryMutationProvider
from ...models import ConnectionConfig from ...models import BrokerConfig, ConnectionConfig
from ...source_registry import ( from ...source_registry import (
McpSourceRegistryEntry, McpSourceRegistryEntry,
SourceRegistryFile, SourceRegistryFile,
SourceRegistryStore, SourceRegistryStore,
) )
from .connection_service import ConnectionService
@dataclass(slots=True) @dataclass(slots=True)
@@ -24,6 +25,9 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
source_registry_store: SourceRegistryStore source_registry_store: SourceRegistryStore
config_connections: Sequence[ConnectionConfig] = field(default_factory=tuple) config_connections: Sequence[ConnectionConfig] = field(default_factory=tuple)
connection_service: ConnectionService | None = None
config: BrokerConfig | None = None
ensure_adapter: Callable[[ConnectionConfig], None] | None = None
# -- read helpers ------------------------------------------------------- # -- read helpers -------------------------------------------------------
@@ -118,3 +122,41 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
sources = [s for s in registry.sources if s.id != source_id] sources = [s for s in registry.sources if s.id != source_id]
self._save(sources) self._save(sources)
return {"removed": True, "source_id": source_id} return {"removed": True, "source_id": source_id}
def apply_registry_changes(self) -> dict[str, Any]:
"""Reconcile desired registry state into the live service connection graph.
This mirrors config reload reconciliation, but it only applies persisted
registry state. It does not mutate config files or remount FastMCP proxy
providers.
"""
if self.connection_service is None or self.config is None:
raise RuntimeError("source registry apply requires runtime service context")
before = {connection.id: connection for connection in self.connection_service.list_all()}
self.connection_service.sync_connections_from_config(
self.config,
source_registry_store=self.source_registry_store,
)
after = {connection.id: connection for connection in self.connection_service.list_all()}
if self.ensure_adapter is not None:
for connection in after.values():
self.ensure_adapter(connection)
before_ids = set(before)
after_ids = set(after)
updated = sorted(
source_id
for source_id in before_ids & after_ids
if before[source_id] != after[source_id]
)
registry = self._load()
return {
"applied": True,
"registered": sorted(after_ids - before_ids),
"updated": updated,
"removed": sorted(before_ids - after_ids),
"connection_count": len(after),
"registry_entry_count": len(registry.sources),
}
@@ -79,3 +79,9 @@ class RpcSourceRegistryClientMixin:
"workflow.admin.source_registry.remove", "workflow.admin.source_registry.remove",
{"source_id": source_id}, {"source_id": source_id},
) )
async def apply_registry_changes(self) -> dict[str, Any]:
return await self._call(
"workflow.admin.source_registry.apply",
{},
)
@@ -10,6 +10,7 @@ from wf_server import WorkflowServer
from .errors import WorkflowRpcError, raise_workflow_rpc_error from .errors import WorkflowRpcError, raise_workflow_rpc_error
from .models import ( from .models import (
AddRegistryEntryParams, AddRegistryEntryParams,
ApplyRegistryChangesParams,
InspectRegistryEntryParams, InspectRegistryEntryParams,
ListRegistryEntriesParams, ListRegistryEntriesParams,
RegistryEntryIdParams, RegistryEntryIdParams,
@@ -149,3 +150,16 @@ def register_methods(
) )
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.admin.source_registry.apply",
errors=[WorkflowRpcError],
)
async def workflow_admin_source_registry_apply(
params: ApplyRegistryChangesParams = RpcParams(),
) -> dict[str, Any]:
admin = _require_source_registry_admin(server, operation="apply")
try:
return await admin.apply_registry_changes()
except (ValueError, KeyError, LookupError, FileNotFoundError, RuntimeError) as exc:
raise_workflow_rpc_error(exc)
+4
View File
@@ -201,3 +201,7 @@ class UpdateRegistryEntryParams(RpcParamsModel):
class RegistryEntryIdParams(RpcParamsModel): class RegistryEntryIdParams(RpcParamsModel):
source_id: str = Field(min_length=1) source_id: str = Field(min_length=1)
class ApplyRegistryChangesParams(RpcParamsModel):
pass
@@ -361,3 +361,42 @@ def test_api_with_mutation_satisfies_surface_protocol() -> None:
api, _ = _mutation_api(entries=[FakeRegistryEntry(id="x")]) api, _ = _mutation_api(entries=[FakeRegistryEntry(id="x")])
surface: WorkflowSourceRegistrySurface = api surface: WorkflowSourceRegistrySurface = api
assert surface is not None 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 "enable" in result.output
assert "disable" in result.output assert "disable" in result.output
assert "remove" in result.output assert "remove" in result.output
assert "apply" in result.output
def test_wf_admin_registry_list_help_exists() -> None: 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: def test_read_json_arg_missing_file() -> None:
with pytest.raises(Exception, match="file not found"): with pytest.raises(Exception, match="file not found"):
_read_json_arg(None, "/nonexistent/file.json", "--input/--input-file") _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"): with pytest.raises(KeyError, match="unknown registry source"):
provider.remove_registry_entry("no.such.id") 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( connections = await _rpc(
http_client, "workflow.admin.connections.list", {} http_client, "workflow.admin.connections.list", {}
) )
events = await _rpc(http_client, "workflow.admin.events.list", {})
assert connections["result"]["connections"][0]["id"] == "demo.default" 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( 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_api import WorkflowSourceRegistryApi
from wf_server import build_local_static_workflow_server from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app 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) @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", "workflow.admin.source_registry.remove",
{"source_id": "s"}, {"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", {})]