feat: add server store role overrides
This commit is contained in:
@@ -252,6 +252,13 @@ implementation state.
|
|||||||
`server.store` as the fallback for every missing role. First implementation
|
`server.store` as the fallback for every missing role. First implementation
|
||||||
should stay filesystem-only; secret managers, SQL, and object stores are
|
should stay filesystem-only; secret managers, SQL, and object stores are
|
||||||
later backend implementations.
|
later backend implementations.
|
||||||
|
First role-specific store slice complete: neutral config now accepts optional
|
||||||
|
`server.stores.workflow`, `server.stores.auth`,
|
||||||
|
`server.stores.source_registry`, and `server.stores.catalog_cache`
|
||||||
|
filesystem overrides. Missing roles still fall back to `server.store`.
|
||||||
|
MCP compatibility still uses one `FileStore` class for auth and catalog
|
||||||
|
snapshots internally; the separate catalog root is carried for the future
|
||||||
|
store split.
|
||||||
- Completed: `wf run watch` starts run progress UX with polling over existing
|
- Completed: `wf run watch` starts run progress UX with polling over existing
|
||||||
`inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP
|
`inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP
|
||||||
progress remains deferred until polling UX proves insufficient.
|
progress remains deferred until polling UX proves insufficient.
|
||||||
|
|||||||
@@ -252,6 +252,9 @@ optional role-specific overrides without breaking existing files:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Implementation status: first filesystem-only slice implemented. Role overrides
|
||||||
|
are optional and fall back to `server.store`.
|
||||||
|
|
||||||
Resolution rule:
|
Resolution rule:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+4
-3
@@ -55,9 +55,10 @@ The old `store_root` field maps to
|
|||||||
|
|
||||||
`server.store` is currently the default root for all file-backed server state:
|
`server.store` is currently the default root for all file-backed server state:
|
||||||
workflow artifacts/deployments/runs, source registry entries, catalog cache, and
|
workflow artifacts/deployments/runs, source registry entries, catalog cache, and
|
||||||
local/dev auth records. Future configs may add role-specific store overrides
|
local/dev auth records. Role-specific store overrides are now supported via
|
||||||
such as `server.stores.auth` or `server.stores.source_registry`; missing roles
|
`server.stores.*` (e.g. `server.stores.workflow`, `server.stores.auth`,
|
||||||
should continue to fall back to `server.store`.
|
`server.stores.source_registry`, `server.stores.catalog_cache`); missing roles
|
||||||
|
continue to fall back to `server.store`.
|
||||||
|
|
||||||
Start a JSON-RPC server backed by MCP broker config and MCP-capable sources:
|
Start a JSON-RPC server backed by MCP broker config and MCP-capable sources:
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ def load_cli_context(
|
|||||||
config = load_workflow_config(resolved_config_path)
|
config = load_workflow_config(resolved_config_path)
|
||||||
target = config.client.target
|
target = config.client.target
|
||||||
if force_local or isinstance(target, LocalTargetConfig):
|
if force_local or isinstance(target, LocalTargetConfig):
|
||||||
store = config.server.store
|
store = config.server.workflow_store
|
||||||
if not isinstance(store, FilesystemStoreConfig):
|
if not isinstance(store, FilesystemStoreConfig):
|
||||||
raise ValueError("local CLI target currently requires filesystem store")
|
raise ValueError("local CLI target currently requires filesystem store")
|
||||||
server = build_local_static_workflow_server(store.root)
|
server = build_local_static_workflow_server(store.root)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .models import (
|
|||||||
RpcHttpTargetConfig,
|
RpcHttpTargetConfig,
|
||||||
RpcHttpTransportConfig,
|
RpcHttpTransportConfig,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
|
ServerStoresConfig,
|
||||||
SourceConfigOwnership,
|
SourceConfigOwnership,
|
||||||
SourceTransportConfig,
|
SourceTransportConfig,
|
||||||
StdioSourceTransportConfig,
|
StdioSourceTransportConfig,
|
||||||
@@ -27,6 +28,7 @@ __all__ = [
|
|||||||
"RpcHttpTargetConfig",
|
"RpcHttpTargetConfig",
|
||||||
"RpcHttpTransportConfig",
|
"RpcHttpTransportConfig",
|
||||||
"ServerConfig",
|
"ServerConfig",
|
||||||
|
"ServerStoresConfig",
|
||||||
"SourceConfigOwnership",
|
"SourceConfigOwnership",
|
||||||
"SourceTransportConfig",
|
"SourceTransportConfig",
|
||||||
"StdioSourceTransportConfig",
|
"StdioSourceTransportConfig",
|
||||||
|
|||||||
+47
-14
@@ -3,7 +3,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .models import FilesystemStoreConfig, WorkflowConfigFile
|
from .models import (
|
||||||
|
FilesystemStoreConfig,
|
||||||
|
ServerStoresConfig,
|
||||||
|
StoreConfig,
|
||||||
|
WorkflowConfigFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_workflow_config(path: str | Path) -> WorkflowConfigFile:
|
def load_workflow_config(path: str | Path) -> WorkflowConfigFile:
|
||||||
@@ -11,22 +16,50 @@ def load_workflow_config(path: str | Path) -> WorkflowConfigFile:
|
|||||||
|
|
||||||
Relative filesystem store roots are config-file relative so `wf --config`
|
Relative filesystem store roots are config-file relative so `wf --config`
|
||||||
behaves the same regardless of the caller's current working directory.
|
behaves the same regardless of the caller's current working directory.
|
||||||
|
Role-specific store overrides follow the same rule.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config_path = Path(path)
|
config_path = Path(path)
|
||||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
config = WorkflowConfigFile.model_validate(data)
|
config = WorkflowConfigFile.model_validate(data)
|
||||||
store = config.server.store
|
return _resolve_store_paths(config, base_dir=config_path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_store_paths(
|
||||||
|
config: WorkflowConfigFile,
|
||||||
|
*,
|
||||||
|
base_dir: Path,
|
||||||
|
) -> WorkflowConfigFile:
|
||||||
|
server = config.server
|
||||||
|
resolved_stores = ServerStoresConfig(
|
||||||
|
workflow=_resolve_store(server.stores.workflow, base_dir=base_dir),
|
||||||
|
auth=_resolve_store(server.stores.auth, base_dir=base_dir),
|
||||||
|
source_registry=_resolve_store(
|
||||||
|
server.stores.source_registry,
|
||||||
|
base_dir=base_dir,
|
||||||
|
),
|
||||||
|
catalog_cache=_resolve_store(
|
||||||
|
server.stores.catalog_cache,
|
||||||
|
base_dir=base_dir,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return config.model_copy(
|
||||||
|
update={
|
||||||
|
"server": server.model_copy(
|
||||||
|
update={
|
||||||
|
"store": _resolve_store(server.store, base_dir=base_dir),
|
||||||
|
"stores": resolved_stores,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_store(
|
||||||
|
store: StoreConfig | None,
|
||||||
|
*,
|
||||||
|
base_dir: Path,
|
||||||
|
) -> StoreConfig | None:
|
||||||
if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute():
|
if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute():
|
||||||
config = config.model_copy(
|
return store.model_copy(update={"root": (base_dir / store.root).resolve()})
|
||||||
update={
|
return store
|
||||||
"server": config.server.model_copy(
|
|
||||||
update={
|
|
||||||
"store": store.model_copy(
|
|
||||||
update={"root": (config_path.parent / store.root).resolve()}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return config
|
|
||||||
|
|||||||
@@ -139,8 +139,23 @@ SourceConfig = Annotated[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ServerStoresConfig(WorkflowConfigModel):
|
||||||
|
"""Optional role-specific store overrides.
|
||||||
|
|
||||||
|
Missing roles fall back to `ServerConfig.store`. Keep this config role-based
|
||||||
|
so future backends can split workflow records, auth, desired sources, and
|
||||||
|
cache storage independently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
workflow: StoreConfig | None = None
|
||||||
|
auth: StoreConfig | None = None
|
||||||
|
source_registry: StoreConfig | None = None
|
||||||
|
catalog_cache: StoreConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(WorkflowConfigModel):
|
class ServerConfig(WorkflowConfigModel):
|
||||||
store: StoreConfig = Field(default_factory=FilesystemStoreConfig)
|
store: StoreConfig = Field(default_factory=FilesystemStoreConfig)
|
||||||
|
stores: ServerStoresConfig = Field(default_factory=ServerStoresConfig)
|
||||||
transports: list[ServerTransportConfig] = Field(default_factory=list)
|
transports: list[ServerTransportConfig] = Field(default_factory=list)
|
||||||
sources: list[SourceConfig] = Field(default_factory=list)
|
sources: list[SourceConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -153,6 +168,22 @@ class ServerConfig(WorkflowConfigModel):
|
|||||||
seen.add(source.id)
|
seen.add(source.id)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workflow_store(self) -> StoreConfig:
|
||||||
|
return self.stores.workflow or self.store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_store(self) -> StoreConfig:
|
||||||
|
return self.stores.auth or self.store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_registry_store(self) -> StoreConfig:
|
||||||
|
return self.stores.source_registry or self.store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def catalog_cache_store(self) -> StoreConfig:
|
||||||
|
return self.stores.catalog_cache or self.store
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfigFile(WorkflowConfigModel):
|
class WorkflowConfigFile(WorkflowConfigModel):
|
||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfi
|
|||||||
|
|
||||||
from ..control import BrokerConfigFile, ConnectionConfigFile
|
from ..control import BrokerConfigFile, ConnectionConfigFile
|
||||||
from ..models import BrokerConfig
|
from ..models import BrokerConfig
|
||||||
|
from .models import BrokerStoreRoots
|
||||||
from ..runtime import McpRuntimePool, PersistentSessionFactory
|
from ..runtime import McpRuntimePool, PersistentSessionFactory
|
||||||
from ..sdk import McpSdkAdapter
|
from ..sdk import McpSdkAdapter
|
||||||
from ..source_registry import (
|
from ..source_registry import (
|
||||||
@@ -104,10 +105,32 @@ def load_broker_config(path: str | Path) -> BrokerConfig:
|
|||||||
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
|
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _filesystem_store_root(store: object, *, role: str) -> Path:
|
||||||
|
if not isinstance(store, FilesystemStoreConfig):
|
||||||
|
raise ValueError(f"MCP-backed workflow server requires filesystem {role} store")
|
||||||
|
return store.root
|
||||||
|
|
||||||
|
|
||||||
def broker_config_from_workflow_config(config: WorkflowConfigFile) -> BrokerConfig:
|
def broker_config_from_workflow_config(config: WorkflowConfigFile) -> BrokerConfig:
|
||||||
"""Create MCP broker runtime config from neutral workflow server config."""
|
"""Create MCP broker runtime config from neutral workflow server config."""
|
||||||
return BrokerConfig(
|
return BrokerConfig(
|
||||||
store_root=config.server.store.root,
|
store_root=config.server.store.root,
|
||||||
|
store_roots=BrokerStoreRoots(
|
||||||
|
default_root=config.server.store.root,
|
||||||
|
workflow_root=_filesystem_store_root(
|
||||||
|
config.server.workflow_store,
|
||||||
|
role="workflow",
|
||||||
|
),
|
||||||
|
auth_root=_filesystem_store_root(config.server.auth_store, role="auth"),
|
||||||
|
source_registry_root=_filesystem_store_root(
|
||||||
|
config.server.source_registry_store,
|
||||||
|
role="source_registry",
|
||||||
|
),
|
||||||
|
catalog_cache_root=_filesystem_store_root(
|
||||||
|
config.server.catalog_cache_store,
|
||||||
|
role="catalog_cache",
|
||||||
|
),
|
||||||
|
),
|
||||||
connections=[
|
connections=[
|
||||||
workflow_mcp_source_to_connection_config(source)
|
workflow_mcp_source_to_connection_config(source)
|
||||||
for source in config.server.sources
|
for source in config.server.sources
|
||||||
@@ -139,9 +162,13 @@ def migrate_broker_config_file(path: str | Path) -> WorkflowConfigFile:
|
|||||||
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
||||||
"""Create a broker service with SDK adapters for configured connections."""
|
"""Create a broker service with SDK adapters for configured connections."""
|
||||||
runtime_factory = PersistentSessionFactory()
|
runtime_factory = PersistentSessionFactory()
|
||||||
workflow_stores = file_workflow_stores(config.store_root)
|
store_roots = config.store_roots or BrokerStoreRoots.from_default(config.store_root)
|
||||||
|
workflow_stores = file_workflow_stores(store_roots.workflow_root)
|
||||||
|
# FileStore still owns both auth files and catalog snapshots. Role roots are
|
||||||
|
# carried separately so a later FileStore split can move catalog_cache without a
|
||||||
|
# config migration.
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(config.store_root),
|
store=FileStore(store_roots.auth_root),
|
||||||
artifact_store=workflow_stores.artifact_store,
|
artifact_store=workflow_stores.artifact_store,
|
||||||
draft_workspace_store=workflow_stores.draft_workspace_store,
|
draft_workspace_store=workflow_stores.draft_workspace_store,
|
||||||
run_store=workflow_stores.run_store,
|
run_store=workflow_stores.run_store,
|
||||||
@@ -150,7 +177,7 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
|||||||
# across sequential workflow nodes.
|
# across sequential workflow nodes.
|
||||||
tool_executor=McpRuntimePool(runtime_factory.create),
|
tool_executor=McpRuntimePool(runtime_factory.create),
|
||||||
)
|
)
|
||||||
source_registry_store = FileSourceRegistryStore(config.store_root)
|
source_registry_store = FileSourceRegistryStore(store_roots.source_registry_root)
|
||||||
service.sync_connections_from_config(
|
service.sync_connections_from_config(
|
||||||
config,
|
config,
|
||||||
source_registry_store=source_registry_store,
|
source_registry_store=source_registry_store,
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ from typing import Any, Literal
|
|||||||
SourceConfigOwnership = Literal["locked", "seed"]
|
SourceConfigOwnership = Literal["locked", "seed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BrokerStoreRoots:
|
||||||
|
"""Resolved filesystem roots for MCP compatibility stores.
|
||||||
|
|
||||||
|
`default_root` preserves legacy `store_root` behavior. Role roots let
|
||||||
|
neutral config split workflow records, auth, desired source registry, and
|
||||||
|
catalog/cache storage without changing legacy config files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_root: Path
|
||||||
|
workflow_root: Path
|
||||||
|
auth_root: Path
|
||||||
|
source_registry_root: Path
|
||||||
|
catalog_cache_root: Path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_default(cls, root: Path) -> BrokerStoreRoots:
|
||||||
|
return cls(
|
||||||
|
default_root=root,
|
||||||
|
workflow_root=root,
|
||||||
|
auth_root=root,
|
||||||
|
source_registry_root=root,
|
||||||
|
catalog_cache_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ConnectionConfig:
|
class ConnectionConfig:
|
||||||
id: str
|
id: str
|
||||||
@@ -21,10 +47,16 @@ class ConnectionConfig:
|
|||||||
class BrokerConfig:
|
class BrokerConfig:
|
||||||
store_root: Path
|
store_root: Path
|
||||||
connections: list[ConnectionConfig] = field(default_factory=list)
|
connections: list[ConnectionConfig] = field(default_factory=list)
|
||||||
|
store_roots: BrokerStoreRoots | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.store_roots is None:
|
||||||
|
self.store_roots = BrokerStoreRoots.from_default(self.store_root)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BrokerConfig",
|
"BrokerConfig",
|
||||||
|
"BrokerStoreRoots",
|
||||||
"ConnectionConfig",
|
"ConnectionConfig",
|
||||||
"SourceConfigOwnership",
|
"SourceConfigOwnership",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -107,10 +107,13 @@ def workflow_server_from_service(
|
|||||||
def build_workflow_server_from_config(config: BrokerConfig) -> WorkflowServer:
|
def build_workflow_server_from_config(config: BrokerConfig) -> WorkflowServer:
|
||||||
"""Build a neutral WorkflowServer backed by MCP broker runtime services."""
|
"""Build a neutral WorkflowServer backed by MCP broker runtime services."""
|
||||||
service = build_service_from_config(config)
|
service = build_service_from_config(config)
|
||||||
|
assert config.store_roots is not None
|
||||||
return workflow_server_from_service(
|
return workflow_server_from_service(
|
||||||
service,
|
service,
|
||||||
config=config,
|
config=config,
|
||||||
source_registry_store=FileSourceRegistryStore(config.store_root),
|
source_registry_store=FileSourceRegistryStore(
|
||||||
|
config.store_roots.source_registry_root
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from wf_api.models import RawWorkflowPlan
|
from wf_api.models import RawWorkflowPlan
|
||||||
from wf_mcp.auth import AuthRecord
|
from wf_mcp.auth import AuthRecord
|
||||||
from wf_mcp.broker.models import BrokerConfig, ConnectionConfig, SourceConfigOwnership
|
from wf_mcp.broker.models import (
|
||||||
|
BrokerConfig,
|
||||||
|
BrokerStoreRoots,
|
||||||
|
ConnectionConfig,
|
||||||
|
SourceConfigOwnership,
|
||||||
|
)
|
||||||
from wf_mcp.catalog.models import CatalogSnapshot, dump_catalog_snapshot
|
from wf_mcp.catalog.models import CatalogSnapshot, dump_catalog_snapshot
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthRecord",
|
"AuthRecord",
|
||||||
"BrokerConfig",
|
"BrokerConfig",
|
||||||
|
"BrokerStoreRoots",
|
||||||
"CatalogSnapshot",
|
"CatalogSnapshot",
|
||||||
"ConnectionConfig",
|
"ConnectionConfig",
|
||||||
"RawWorkflowPlan",
|
"RawWorkflowPlan",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def build_workflow_server_from_workflow_config(
|
|||||||
"""
|
"""
|
||||||
if _has_mcp_sources(config):
|
if _has_mcp_sources(config):
|
||||||
return _build_mcp_workflow_server_from_workflow_config(config)
|
return _build_mcp_workflow_server_from_workflow_config(config)
|
||||||
store = config.server.store
|
store = config.server.workflow_store
|
||||||
if not isinstance(store, FilesystemStoreConfig):
|
if not isinstance(store, FilesystemStoreConfig):
|
||||||
# Roadmap: SQL/transactional stores are deferred until the remote server
|
# Roadmap: SQL/transactional stores are deferred until the remote server
|
||||||
# storage boundary is proven with file-backed stores.
|
# storage boundary is proven with file-backed stores.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def serve(
|
|||||||
|
|
||||||
if config is not None:
|
if config is not None:
|
||||||
workflow_config = load_workflow_config(config)
|
workflow_config = load_workflow_config(config)
|
||||||
store = workflow_config.server.store
|
store = workflow_config.server.workflow_store
|
||||||
if server is None and store_root is None:
|
if server is None and store_root is None:
|
||||||
server = build_workflow_server_from_workflow_config(workflow_config)
|
server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
elif server is None:
|
elif server is None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from wf_cli.context import (
|
|||||||
rpc_timeout_from_context,
|
rpc_timeout_from_context,
|
||||||
rpc_url_from_context,
|
rpc_url_from_context,
|
||||||
)
|
)
|
||||||
|
from wf_server import build_local_static_workflow_server
|
||||||
|
|
||||||
|
|
||||||
def _typer_context(obj: object | None) -> typer.Context:
|
def _typer_context(obj: object | None) -> typer.Context:
|
||||||
@@ -96,3 +97,43 @@ def test_load_cli_context_builds_service_and_handlers(tmp_path: Path) -> None:
|
|||||||
context.handlers.context.draft_workspace_store is service.draft_workspace_store
|
context.handlers.context.draft_workspace_store is service.draft_workspace_store
|
||||||
)
|
)
|
||||||
assert context.handlers.context.run_store is service.run_store
|
assert context.handlers.context.run_store is service.run_store
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_cli_context_local_uses_workflow_store_override(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "wf.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"client": {"target": {"kind": "local"}},
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".default"},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".workflow",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_build_local_static_workflow_server(root):
|
||||||
|
captured["store_root"] = root
|
||||||
|
return build_local_static_workflow_server(tmp_path / "actual")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_cli.context.build_local_static_workflow_server",
|
||||||
|
fake_build_local_static_workflow_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = load_cli_context(config_path)
|
||||||
|
|
||||||
|
assert context.service is None
|
||||||
|
assert captured["store_root"] == (tmp_path / ".workflow").resolve()
|
||||||
|
|||||||
@@ -274,6 +274,38 @@ def test_workflow_config_rejects_unsafe_mcp_source_id() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_parses_role_specific_store_overrides() -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {"kind": "filesystem", "root": ".wf_workflow"},
|
||||||
|
"auth": {"kind": "filesystem", "root": ".wf_auth"},
|
||||||
|
"source_registry": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".wf_sources",
|
||||||
|
},
|
||||||
|
"catalog_cache": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".wf_catalog",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(config.server.stores.workflow, FilesystemStoreConfig)
|
||||||
|
assert config.server.stores.workflow.root.as_posix() == ".wf_workflow"
|
||||||
|
assert isinstance(config.server.stores.auth, FilesystemStoreConfig)
|
||||||
|
assert config.server.stores.auth.root.as_posix() == ".wf_auth"
|
||||||
|
assert isinstance(config.server.stores.source_registry, FilesystemStoreConfig)
|
||||||
|
assert config.server.stores.source_registry.root.as_posix() == ".wf_sources"
|
||||||
|
assert isinstance(config.server.stores.catalog_cache, FilesystemStoreConfig)
|
||||||
|
assert config.server.stores.catalog_cache.root.as_posix() == ".wf_catalog"
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_config_rejects_duplicate_source_ids_across_kinds() -> None:
|
def test_workflow_config_rejects_duplicate_source_ids_across_kinds() -> None:
|
||||||
with pytest.raises(ValidationError, match="duplicate source id"):
|
with pytest.raises(ValidationError, match="duplicate source id"):
|
||||||
WorkflowConfigFile.model_validate(
|
WorkflowConfigFile.model_validate(
|
||||||
@@ -293,3 +325,80 @@ def test_workflow_config_rejects_duplicate_source_ids_across_kinds() -> None:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_workflow_config_resolves_role_store_paths_relative_to_config(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "nested" / "wf.json"
|
||||||
|
config_path.parent.mkdir()
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".default_store"},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".workflow_store",
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".auth_store",
|
||||||
|
},
|
||||||
|
"source_registry": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".source_store",
|
||||||
|
},
|
||||||
|
"catalog_cache": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".catalog_store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_workflow_config(config_path)
|
||||||
|
|
||||||
|
assert config.server.store.root == (
|
||||||
|
config_path.parent / ".default_store"
|
||||||
|
).resolve()
|
||||||
|
assert config.server.stores.workflow is not None
|
||||||
|
assert config.server.stores.workflow.root == (
|
||||||
|
config_path.parent / ".workflow_store"
|
||||||
|
).resolve()
|
||||||
|
assert config.server.stores.auth is not None
|
||||||
|
assert config.server.stores.auth.root == (
|
||||||
|
config_path.parent / ".auth_store"
|
||||||
|
).resolve()
|
||||||
|
assert config.server.stores.source_registry is not None
|
||||||
|
assert config.server.stores.source_registry.root == (
|
||||||
|
config_path.parent / ".source_store"
|
||||||
|
).resolve()
|
||||||
|
assert config.server.stores.catalog_cache is not None
|
||||||
|
assert config.server.stores.catalog_cache.root == (
|
||||||
|
config_path.parent / ".catalog_store"
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_config_resolves_missing_role_stores_to_default_store() -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".default"},
|
||||||
|
"stores": {
|
||||||
|
"auth": {"kind": "filesystem", "root": ".auth"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.server.workflow_store.root.as_posix() == ".default"
|
||||||
|
assert config.server.auth_store.root.as_posix() == ".auth"
|
||||||
|
assert config.server.source_registry_store.root.as_posix() == ".default"
|
||||||
|
assert config.server.catalog_cache_store.root.as_posix() == ".default"
|
||||||
|
|||||||
@@ -106,3 +106,88 @@ def test_broker_config_from_workflow_config_ignores_non_mcp_sources(
|
|||||||
|
|
||||||
assert broker_config.store_root == tmp_path / "store"
|
assert broker_config.store_root == tmp_path / "store"
|
||||||
assert broker_config.connections == []
|
assert broker_config.connections == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_config_from_workflow_config_carries_role_store_roots() -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".default"},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {"kind": "filesystem", "root": ".workflow"},
|
||||||
|
"auth": {"kind": "filesystem", "root": ".auth"},
|
||||||
|
"source_registry": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".sources",
|
||||||
|
},
|
||||||
|
"catalog_cache": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".catalog",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = broker_config_from_workflow_config(config)
|
||||||
|
|
||||||
|
assert broker.store_roots.workflow_root == Path(".workflow")
|
||||||
|
assert broker.store_roots.auth_root == Path(".auth")
|
||||||
|
assert broker.store_roots.source_registry_root == Path(".sources")
|
||||||
|
assert broker.store_roots.catalog_cache_root == Path(".catalog")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_service_from_neutral_config_uses_role_store_roots(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": str(tmp_path / "default")},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": str(tmp_path / "workflow"),
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": str(tmp_path / "auth"),
|
||||||
|
},
|
||||||
|
"source_registry": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": str(tmp_path / "sources"),
|
||||||
|
},
|
||||||
|
"catalog_cache": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": str(tmp_path / "catalog"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "everything.default",
|
||||||
|
"provider": "everything",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {
|
||||||
|
"kind": "stdio",
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-everything"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
from wf_mcp.broker.config import build_service_from_config
|
||||||
|
|
||||||
|
broker = broker_config_from_workflow_config(config)
|
||||||
|
service = build_service_from_config(broker)
|
||||||
|
|
||||||
|
assert service.store.root == tmp_path / "auth"
|
||||||
|
assert service.artifact_store.root == tmp_path / "workflow"
|
||||||
|
assert service.draft_workspace_store.root == tmp_path / "workflow"
|
||||||
|
assert service.run_store.root == tmp_path / "workflow"
|
||||||
|
assert (tmp_path / "sources").exists()
|
||||||
|
|||||||
@@ -374,3 +374,52 @@ def test_rpc_server_cli_rejects_store_root_with_mcp_source_config(tmp_path) -> N
|
|||||||
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "--store-root cannot override MCP-source config" in result.output
|
assert "--store-root cannot override MCP-source config" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_server_cli_config_uses_workflow_store_override(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "wf.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".default"},
|
||||||
|
"stores": {
|
||||||
|
"workflow": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".workflow",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_build_server(config):
|
||||||
|
captured["workflow_store_root"] = config.server.workflow_store.root
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def fake_create_rpc_app(server, *, rpc_path="/rpc"):
|
||||||
|
captured["server"] = server
|
||||||
|
captured["rpc_path"] = rpc_path
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||||
|
captured["app"] = app_obj
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_transport_rpc_http.cli.build_workflow_server_from_workflow_config",
|
||||||
|
fake_build_server,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("wf_transport_rpc_http.cli.create_rpc_app", fake_create_rpc_app)
|
||||||
|
monkeypatch.setattr("wf_transport_rpc_http.cli.uvicorn.run", fake_uvicorn_run)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app, ["--config", str(config_path)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured["workflow_store_root"] == (tmp_path / ".workflow").resolve()
|
||||||
|
|||||||
Reference in New Issue
Block a user