feat: add source config ownership policy

This commit is contained in:
lda
2026-06-04 17:49:05 +07:00 Unverified
parent b211eef1c7
commit 00305c1398
15 changed files with 1112 additions and 36 deletions
+17 -4
View File
@@ -14,6 +14,8 @@ class WorkflowSourceRegistryProvider(Protocol):
def config_source_ids(self) -> Set[str]: ...
def config_source_ownership(self) -> Mapping[str, str]: ...
@runtime_checkable
class WorkflowSourceRegistryMutationProvider(Protocol):
@@ -58,9 +60,12 @@ class WorkflowSourceRegistryApi:
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
ownership = self._provider.config_source_ownership()
entries = sorted(
(
_entry_summary(_payload(item), self._provider.config_source_ids())
_entry_summary(
_payload(item), self._provider.config_source_ids(), ownership
)
for item in self._provider.list_registry_entries()
),
key=lambda item: str(item.get("id", "")),
@@ -77,12 +82,15 @@ class WorkflowSourceRegistryApi:
*,
source_id: str,
) -> dict[str, Any]:
ownership = self._provider.config_source_ownership()
for item in self._provider.list_registry_entries():
entry = _payload(item)
if entry.get("id") == source_id:
return {
"entry": entry,
"shadowed_by_config": self._is_shadowed(source_id),
"config_ownership": ownership.get(source_id),
"mutable": ownership.get(source_id) != "locked",
}
raise KeyError(f"unknown registry source {source_id!r}")
@@ -174,11 +182,14 @@ def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
)
def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, Any]:
def _entry_summary(
entry: dict[str, Any], shadowed_ids: Set[str], ownership: Mapping[str, str]
) -> dict[str, Any]:
transport = entry.get("transport")
transport_kind = transport.get("kind") if isinstance(transport, Mapping) else None
entry_id = entry["id"]
return {
"id": entry["id"],
"id": entry_id,
"kind": entry["kind"],
"enabled": entry["enabled"],
"provider": entry.get("provider"),
@@ -186,5 +197,7 @@ def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, A
"profile": entry.get("profile"),
"transport_kind": transport_kind,
"auth_ref": entry.get("auth_ref"),
"shadowed_by_config": entry["id"] in shadowed_ids,
"shadowed_by_config": entry_id in shadowed_ids,
"config_ownership": ownership.get(entry_id),
"mutable": ownership.get(entry_id) != "locked",
}
+63 -15
View File
@@ -5,7 +5,12 @@ from dataclasses import dataclass, field
from ...connections import ConnectionRegistry, parse_connection_id
from ...models import BrokerConfig, ConnectionConfig
from ...shared.names import RESERVED_CONNECTION_IDS
from ...source_registry import SourceRegistryStore, registry_entry_to_connection_config
from ...source_registry import (
SourceRegistryFile,
SourceRegistryStore,
connection_config_to_registry_entry,
registry_entry_to_connection_config,
)
from .events import BrokerEventRecorder
from .source_catalog import SourceCatalogService
@@ -61,25 +66,68 @@ class ConnectionService:
source_registry_store: SourceRegistryStore | None = None,
) -> None:
"""Reconcile registry/source state after the public server reloads config."""
# Config-defined connections win over registry entries with the same id;
# registry entries fill ids not present in config.
connections = list(config.connections)
config_ids = {connection.id for connection in connections}
config_by_id = {connection.id: connection for connection in connections}
registry_entries = {}
registry_changed = False
if source_registry_store is not None:
registry = source_registry_store.load_registry()
for entry in registry.sources:
if entry.id in config_ids:
self.events.record_kind(
"source_registry_ignored_config_shadow",
connection_id=entry.id,
payload={
"server": entry.provider,
"account": entry.account,
"reason": "config_connection_takes_precedence",
},
registry_entries = registry.source_map()
for connection in connections:
if connection.source_config_ownership != "seed":
continue
if connection.id in registry_entries:
continue
seeded = connection_config_to_registry_entry(connection)
registry_entries[seeded.id] = seeded
registry_changed = True
self.events.record_kind(
"source_registry_seeded_from_config",
connection_id=seeded.id,
payload={"server": seeded.provider, "account": seeded.account},
)
if registry_changed:
source_registry_store.save_registry(
SourceRegistryFile(sources=list(registry_entries.values()))
)
merged_connections: list[ConnectionConfig] = []
for connection in connections:
registry_entry = registry_entries.get(connection.id)
if (
connection.source_config_ownership == "seed"
and registry_entry is not None
):
merged_connections.append(
registry_entry_to_connection_config(registry_entry)
)
continue
connections.append(registry_entry_to_connection_config(entry))
merged_connections.append(connection)
merged_ids = {connection.id for connection in merged_connections}
for entry in registry_entries.values():
config_connection = config_by_id.get(entry.id)
if config_connection is not None:
if config_connection.source_config_ownership == "locked":
self.events.record_kind(
"source_registry_ignored_config_shadow",
connection_id=entry.id,
payload={
"server": entry.provider,
"account": entry.account,
"reason": "locked_config_connection_takes_precedence",
},
)
continue
if entry.id not in merged_ids:
merged_connections.append(
registry_entry_to_connection_config(entry)
)
connections = merged_connections
source_catalog = self._source_catalog()
next_ids = {connection.id for connection in connections}
@@ -33,8 +33,20 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
def config_source_ids(self) -> set[str]:
return {connection.id for connection in self.config_connections}
def config_source_ownership(self) -> dict[str, str]:
return {
connection.id: connection.source_config_ownership
for connection in self.config_connections
}
# -- private helpers ----------------------------------------------------
def _config_connection(self, source_id: str) -> ConnectionConfig | None:
for connection in self.config_connections:
if connection.id == source_id:
return connection
return None
def _load(self) -> SourceRegistryFile:
return self.source_registry_store.load_registry()
@@ -58,9 +70,13 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
def add_registry_entry(self, entry: Mapping[str, Any]) -> McpSourceRegistryEntry:
source_id = str(entry["id"])
if source_id in self.config_source_ids():
config_connection = self._config_connection(source_id)
if (
config_connection is not None
and config_connection.source_config_ownership == "locked"
):
raise ValueError(
f"cannot add {source_id!r}: id is shadowed by a config connection"
f"cannot add {source_id!r}: id is locked by a config connection"
)
validated = McpSourceRegistryEntry.model_validate(dict(entry))
registry = self._load()
+3 -1
View File
@@ -5,7 +5,7 @@ from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
from ..models import BrokerConfig, ConnectionConfig
from ..models import BrokerConfig, ConnectionConfig, SourceConfigOwnership
class StdioConnectionMetadata(BaseModel):
@@ -43,6 +43,7 @@ class ConnectionConfigFile(BaseModel):
account: str
enabled: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
source_config_ownership: SourceConfigOwnership = "locked"
@field_validator("metadata", mode="before")
@classmethod
@@ -65,6 +66,7 @@ class ConnectionConfigFile(BaseModel):
account=self.account,
enabled=self.enabled,
metadata=self.metadata,
source_config_ownership=self.source_config_ownership,
)
+5 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, Literal
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
@@ -10,6 +10,9 @@ from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceE
from wf_api.models import RawWorkflowPlan # noqa: F401
SourceConfigOwnership = Literal["locked", "seed"]
@dataclass(slots=True)
class ConnectionConfig:
id: str
@@ -17,6 +20,7 @@ class ConnectionConfig:
account: str
enabled: bool = True
metadata: dict[str, Any] = field(default_factory=dict)
source_config_ownership: SourceConfigOwnership = "locked"
@dataclass(slots=True)
+34
View File
@@ -127,6 +127,39 @@ def registry_entry_to_connection_config(
)
def connection_config_to_registry_entry(
connection: ConnectionConfig,
) -> McpSourceRegistryEntry:
"""Materialize a seed config connection into persisted registry state.
Seed config is bootstrap-only. The registry entry must carry enough source
identity to become the future desired-state owner after first startup.
"""
transport = connection.metadata.get("transport")
if not isinstance(transport, dict):
raise ValueError(
f"seed connection {connection.id!r} requires metadata.transport"
)
profile = connection.metadata.get("profile")
auth_ref = connection.metadata.get("auth_ref")
return McpSourceRegistryEntry.model_validate(
{
"id": connection.id,
"enabled": connection.enabled,
"provider": connection.server,
"account": connection.account,
"profile": profile if isinstance(profile, str) else None,
"transport": transport,
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
"metadata": {
key: value
for key, value in connection.metadata.items()
if key not in {"transport", "profile", "auth_ref", "source_registry"}
},
}
)
__all__ = [
"FileSourceRegistryStore",
"HttpSourceTransport",
@@ -135,5 +168,6 @@ __all__ = [
"SourceRegistryStore",
"SourceTransport",
"StdioSourceTransport",
"connection_config_to_registry_entry",
"registry_entry_to_connection_config",
]