feat: expose source registry admin reads
This commit is contained in:
@@ -130,10 +130,12 @@ implementation state.
|
|||||||
[2026-06-03 source registry next slices](./superpowers/plans/2026-06-03-source-registry-next-slices.md):
|
[2026-06-03 source registry next slices](./superpowers/plans/2026-06-03-source-registry-next-slices.md):
|
||||||
desired-registry admin reads and safe mutation commands remain later
|
desired-registry admin reads and safe mutation commands remain later
|
||||||
slices.
|
slices.
|
||||||
- Next executable source registry slice:
|
- Completed: desired-registry admin read plumbing is available through
|
||||||
[2026-06-04 source registry admin reads](./superpowers/plans/2026-06-04-source-registry-admin-reads.md).
|
`WorkflowSourceRegistryApi`, JSON-RPC methods
|
||||||
It keeps desired registry state separate from observed source inventory
|
(`workflow.admin.source_registry.list` / `.inspect`), and CLI commands
|
||||||
and adds read-only admin/RPC/CLI access before any mutation commands.
|
(`wf admin registry list` / `wf admin registry inspect`). Local/static
|
||||||
|
servers report unavailable instead of empty; a concrete MCP-backed
|
||||||
|
`WorkflowServer` construction path remains future work. No mutations added.
|
||||||
- Longer term: make the MCP frontend an adapter over these neutral workflow,
|
- Longer term: make the MCP frontend an adapter over these neutral workflow,
|
||||||
source-admin, and config-admin surfaces so the old `wf_mcp` server entry
|
source-admin, and config-admin surfaces so the old `wf_mcp` server entry
|
||||||
point can shrink or retire.
|
point can shrink or retire.
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ The next executable slice is startup merge:
|
|||||||
- Emit events/diagnostics for shadowed registry entries.
|
- Emit events/diagnostics for shadowed registry entries.
|
||||||
|
|
||||||
4. **Slice 4: Read Desired Registry Through Admin**
|
4. **Slice 4: Read Desired Registry Through Admin**
|
||||||
- **Status: planned.**
|
- **Status: complete.**
|
||||||
- Expose desired registry entries separately from observed source inventory.
|
- Expose desired registry entries separately from observed source inventory.
|
||||||
- Make the difference visible in API/CLI docs.
|
- `WorkflowSourceRegistryApi` provides neutral read-only access.
|
||||||
|
- JSON-RPC methods `workflow.admin.source_registry.list` / `.inspect`.
|
||||||
|
- CLI commands `wf admin registry list` / `wf admin registry inspect`.
|
||||||
|
- Local/static servers report unavailable instead of empty.
|
||||||
|
- Concrete MCP-backed `WorkflowServer` construction remains future work.
|
||||||
|
|
||||||
5. **Slice 5: Mutation Commands**
|
5. **Slice 5: Mutation Commands**
|
||||||
- Add add/update/enable/disable/remove operations.
|
- Add add/update/enable/disable/remove operations.
|
||||||
@@ -323,8 +327,13 @@ with config-defined connections/sources.
|
|||||||
|
|
||||||
Expose desired registry entries distinctly from observed source inventory.
|
Expose desired registry entries distinctly from observed source inventory.
|
||||||
|
|
||||||
Implementation plan:
|
Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi`
|
||||||
`docs/superpowers/plans/2026-06-04-source-registry-admin-reads.md`.
|
provides neutral read-only access. JSON-RPC methods
|
||||||
|
`workflow.admin.source_registry.list` / `.inspect` are registered. CLI commands
|
||||||
|
`wf admin registry list` / `wf admin registry inspect` are available for targets
|
||||||
|
that expose the surface. Local/static servers report
|
||||||
|
`source_registry_unavailable`. Concrete MCP-backed `WorkflowServer` construction
|
||||||
|
remains future work.
|
||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
|
|||||||
@@ -269,12 +269,14 @@ registry entries.
|
|||||||
|
|
||||||
### Slice 4: Read Registry Through Admin
|
### Slice 4: Read Registry Through Admin
|
||||||
|
|
||||||
Implementation plan:
|
Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi`
|
||||||
[2026-06-04 source registry admin reads](../plans/2026-06-04-source-registry-admin-reads.md).
|
provides neutral read-only access to desired registry entries. JSON-RPC methods
|
||||||
|
`workflow.admin.source_registry.list` and `.inspect` are registered. CLI commands
|
||||||
- Add admin read method for desired registry entries if needed.
|
`wf admin registry list` and `wf admin registry inspect` are available for
|
||||||
- Keep current source inventory list as runtime/observed source inventory.
|
targets that expose the surface. Local/static servers report
|
||||||
- Document difference between desired registry and observed source catalog.
|
`source_registry_unavailable` instead of pretending to have an empty registry.
|
||||||
|
No mutations added. `wf source list` behavior remains unchanged. Concrete
|
||||||
|
MCP-backed `WorkflowServer` construction remains future work.
|
||||||
|
|
||||||
### Slice 5: Mutating RPC/CLI
|
### Slice 5: Mutating RPC/CLI
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability
|
|||||||
from .runs import WorkflowRunApi
|
from .runs import WorkflowRunApi
|
||||||
from .service import WorkflowApi
|
from .service import WorkflowApi
|
||||||
from .source_admin import WorkflowSourceAdminApi
|
from .source_admin import WorkflowSourceAdminApi
|
||||||
|
from .source_registry_admin import (
|
||||||
|
WorkflowSourceRegistryApi,
|
||||||
|
WorkflowSourceRegistryProvider,
|
||||||
|
)
|
||||||
from .surface import (
|
from .surface import (
|
||||||
WorkflowAdminSurface,
|
WorkflowAdminSurface,
|
||||||
WorkflowApiSurface,
|
WorkflowApiSurface,
|
||||||
@@ -33,6 +37,7 @@ from .surface import (
|
|||||||
WorkflowDraftSurface,
|
WorkflowDraftSurface,
|
||||||
WorkflowRunSurface,
|
WorkflowRunSurface,
|
||||||
WorkflowSourceAdminSurface,
|
WorkflowSourceAdminSurface,
|
||||||
|
WorkflowSourceRegistrySurface,
|
||||||
)
|
)
|
||||||
from .wrapper_hints import (
|
from .wrapper_hints import (
|
||||||
MissingDecision,
|
MissingDecision,
|
||||||
@@ -101,6 +106,9 @@ __all__ = [
|
|||||||
"WorkflowRunSurface",
|
"WorkflowRunSurface",
|
||||||
"WorkflowSourceAdminApi",
|
"WorkflowSourceAdminApi",
|
||||||
"WorkflowSourceAdminSurface",
|
"WorkflowSourceAdminSurface",
|
||||||
|
"WorkflowSourceRegistryApi",
|
||||||
|
"WorkflowSourceRegistryProvider",
|
||||||
|
"WorkflowSourceRegistrySurface",
|
||||||
"WorkflowSpecProvider",
|
"WorkflowSpecProvider",
|
||||||
"WorkflowSurfaceCapabilityId",
|
"WorkflowSurfaceCapabilityId",
|
||||||
"WrapperAuthoringHints",
|
"WrapperAuthoringHints",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence, Set
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from wf_platform import page_items
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSourceRegistryProvider(Protocol):
|
||||||
|
"""Provides desired source registry state for read-only admin frontends."""
|
||||||
|
|
||||||
|
def list_registry_entries(self) -> Sequence[Mapping[str, Any] | object]: ...
|
||||||
|
|
||||||
|
def config_source_ids(self) -> Set[str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSourceRegistryApi:
|
||||||
|
"""Protocol-neutral read-only desired source registry operations.
|
||||||
|
|
||||||
|
This surface is intentionally separate from WorkflowSourceAdminApi.
|
||||||
|
WorkflowSourceAdminApi exposes observed/hydrated runtime source inventory.
|
||||||
|
This API exposes desired, server-owned configuration state persisted in the
|
||||||
|
source registry file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider: WorkflowSourceRegistryProvider,
|
||||||
|
) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
|
||||||
|
async def list_registry_entries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
shadowed_ids = set(self._provider.config_source_ids())
|
||||||
|
entries = sorted(
|
||||||
|
(
|
||||||
|
_entry_summary(_payload(item), shadowed_ids)
|
||||||
|
for item in self._provider.list_registry_entries()
|
||||||
|
),
|
||||||
|
key=lambda item: str(item.get("id", "")),
|
||||||
|
)
|
||||||
|
page = page_items(entries, cursor=cursor, limit=limit)
|
||||||
|
return {
|
||||||
|
"entries": list(page.items),
|
||||||
|
"next_cursor": page.next_cursor,
|
||||||
|
"total": page.total,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def inspect_registry_entry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
shadowed_ids = set(self._provider.config_source_ids())
|
||||||
|
for item in self._provider.list_registry_entries():
|
||||||
|
entry = _payload(item)
|
||||||
|
if entry.get("id") == source_id:
|
||||||
|
return {
|
||||||
|
"entry": entry,
|
||||||
|
"shadowed_by_config": source_id in shadowed_ids,
|
||||||
|
}
|
||||||
|
raise KeyError(f"unknown registry source {source_id!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
|
||||||
|
"""Normalize provider objects without depending on MCP registry types."""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return dict(value)
|
||||||
|
if is_dataclass(value) and not isinstance(value, type):
|
||||||
|
return asdict(value)
|
||||||
|
model_dump = getattr(value, "model_dump", None)
|
||||||
|
if callable(model_dump):
|
||||||
|
result = model_dump(mode="json")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
raise TypeError(
|
||||||
|
f"source registry payload object is not serializable: {type(value)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_summary(entry: dict[str, Any], shadowed_ids: set[str]) -> dict[str, Any]:
|
||||||
|
transport = entry.get("transport")
|
||||||
|
transport_kind = transport.get("kind") if isinstance(transport, Mapping) else None
|
||||||
|
return {
|
||||||
|
"id": entry["id"],
|
||||||
|
"kind": entry["kind"],
|
||||||
|
"enabled": entry["enabled"],
|
||||||
|
"provider": entry.get("provider"),
|
||||||
|
"account": entry.get("account"),
|
||||||
|
"profile": entry.get("profile"),
|
||||||
|
"transport_kind": transport_kind,
|
||||||
|
"auth_ref": entry.get("auth_ref"),
|
||||||
|
"shadowed_by_config": entry["id"] in shadowed_ids,
|
||||||
|
}
|
||||||
@@ -226,6 +226,23 @@ class WorkflowAdminSurface(Protocol):
|
|||||||
async def list_events(self) -> dict[str, Any]: ...
|
async def list_events(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSourceRegistrySurface(Protocol):
|
||||||
|
"""Read-only desired source registry methods exposed by platform frontends."""
|
||||||
|
|
||||||
|
async def list_registry_entries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def inspect_registry_entry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: str,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WorkflowAdminSurface",
|
"WorkflowAdminSurface",
|
||||||
"WorkflowApiSurface",
|
"WorkflowApiSurface",
|
||||||
@@ -235,4 +252,5 @@ __all__ = [
|
|||||||
"WorkflowDraftSurface",
|
"WorkflowDraftSurface",
|
||||||
"WorkflowRunSurface",
|
"WorkflowRunSurface",
|
||||||
"WorkflowSourceAdminSurface",
|
"WorkflowSourceAdminSurface",
|
||||||
|
"WorkflowSourceRegistrySurface",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import typer
|
|||||||
from wf_cli.context import load_cli_context_from_typer
|
from wf_cli.context import load_cli_context_from_typer
|
||||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||||
|
|
||||||
|
from . import source_registry
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="admin",
|
name="admin",
|
||||||
help="Read workflow server admin and config state.",
|
help="Read workflow server admin and config state.",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_typer(source_registry.app, name="registry")
|
||||||
|
|
||||||
|
|
||||||
@app.command("connections")
|
@app.command("connections")
|
||||||
def list_connections(
|
def list_connections(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from wf_cli.context import load_cli_context_from_typer
|
||||||
|
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||||
|
from wf_cli.io import emit_json
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="registry",
|
||||||
|
help="List and inspect desired persisted source registry entries.",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("list")
|
||||||
|
def list_registry_entries(
|
||||||
|
ctx: typer.Context,
|
||||||
|
cursor: Annotated[
|
||||||
|
str | None, typer.Option("--cursor", help="Pagination cursor.")
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
int, typer.Option("--limit", min=1, max=100, help="Maximum rows.")
|
||||||
|
] = 50,
|
||||||
|
output_format: Annotated[
|
||||||
|
ListOutputFormat, typer.Option("--format", help="Output format.")
|
||||||
|
] = ListOutputFormat.JSON,
|
||||||
|
) -> None:
|
||||||
|
"""List desired persisted source registry entries."""
|
||||||
|
context = load_cli_context_from_typer(ctx)
|
||||||
|
if context.source_registry_admin is None:
|
||||||
|
raise typer.BadParameter(
|
||||||
|
"source registry admin reads are not available for this server"
|
||||||
|
)
|
||||||
|
payload = asyncio.run(
|
||||||
|
context.source_registry_admin.list_registry_entries(cursor=cursor, limit=limit)
|
||||||
|
)
|
||||||
|
emit_list_payload(
|
||||||
|
payload,
|
||||||
|
collection_key="entries",
|
||||||
|
output_format=output_format,
|
||||||
|
id_field="id",
|
||||||
|
summary_fields=("kind", "enabled", "provider", "account", "transport_kind"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("inspect")
|
||||||
|
def inspect_registry_entry(
|
||||||
|
ctx: typer.Context,
|
||||||
|
source_id: Annotated[str, typer.Argument(help="Source registry entry id.")],
|
||||||
|
) -> None:
|
||||||
|
"""Inspect one desired persisted source registry entry."""
|
||||||
|
context = load_cli_context_from_typer(ctx)
|
||||||
|
if context.source_registry_admin is None:
|
||||||
|
raise typer.BadParameter(
|
||||||
|
"source registry admin reads are not available for this server"
|
||||||
|
)
|
||||||
|
payload = asyncio.run(
|
||||||
|
context.source_registry_admin.inspect_registry_entry(source_id=source_id)
|
||||||
|
)
|
||||||
|
emit_json(payload)
|
||||||
@@ -15,6 +15,7 @@ from wf_api import (
|
|||||||
WorkflowApiSurface,
|
WorkflowApiSurface,
|
||||||
WorkflowSourceAdminApi,
|
WorkflowSourceAdminApi,
|
||||||
WorkflowSourceAdminSurface,
|
WorkflowSourceAdminSurface,
|
||||||
|
WorkflowSourceRegistrySurface,
|
||||||
)
|
)
|
||||||
from wf_config import (
|
from wf_config import (
|
||||||
FilesystemStoreConfig,
|
FilesystemStoreConfig,
|
||||||
@@ -38,6 +39,7 @@ class CliContext:
|
|||||||
handlers: WorkflowApiSurface
|
handlers: WorkflowApiSurface
|
||||||
source_admin: WorkflowSourceAdminSurface
|
source_admin: WorkflowSourceAdminSurface
|
||||||
admin: WorkflowAdminSurface
|
admin: WorkflowAdminSurface
|
||||||
|
source_registry_admin: WorkflowSourceRegistrySurface | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -120,6 +122,7 @@ def load_cli_context(
|
|||||||
handlers=client,
|
handlers=client,
|
||||||
source_admin=client,
|
source_admin=client,
|
||||||
admin=client,
|
admin=client,
|
||||||
|
source_registry_admin=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_legacy_mcp_config(resolved_config_path):
|
if _is_legacy_mcp_config(resolved_config_path):
|
||||||
@@ -165,6 +168,7 @@ def load_cli_context(
|
|||||||
handlers=client,
|
handlers=client,
|
||||||
source_admin=client,
|
source_admin=client,
|
||||||
admin=client,
|
admin=client,
|
||||||
|
source_registry_admin=client,
|
||||||
)
|
)
|
||||||
raise ValueError(f"unsupported workflow target {target!r}")
|
raise ValueError(f"unsupported workflow target {target!r}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from ...models import ConnectionConfig
|
||||||
|
from ...source_registry import SourceRegistryStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SourceRegistryAdminProvider:
|
||||||
|
"""Read desired MCP source registry state without mutating it."""
|
||||||
|
|
||||||
|
source_registry_store: SourceRegistryStore
|
||||||
|
config_connections: Sequence[ConnectionConfig] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
def list_registry_entries(self) -> list[object]:
|
||||||
|
return list(self.source_registry_store.load_registry().sources)
|
||||||
|
|
||||||
|
def config_source_ids(self) -> set[str]:
|
||||||
|
return {connection.id for connection in self.config_connections}
|
||||||
@@ -9,6 +9,7 @@ from wf_api import (
|
|||||||
WorkflowAdminApi,
|
WorkflowAdminApi,
|
||||||
WorkflowApi,
|
WorkflowApi,
|
||||||
WorkflowSourceAdminApi,
|
WorkflowSourceAdminApi,
|
||||||
|
WorkflowSourceRegistryApi,
|
||||||
durable_workflow_api,
|
durable_workflow_api,
|
||||||
)
|
)
|
||||||
from wf_api.local_sources import builtin_sources, get_qualified_spec
|
from wf_api.local_sources import builtin_sources, get_qualified_spec
|
||||||
@@ -260,6 +261,7 @@ class WorkflowServer:
|
|||||||
source_admin: WorkflowSourceAdminApi
|
source_admin: WorkflowSourceAdminApi
|
||||||
admin: WorkflowAdminApi
|
admin: WorkflowAdminApi
|
||||||
events: InMemoryWorkflowEventRecorder
|
events: InMemoryWorkflowEventRecorder
|
||||||
|
source_registry_admin: WorkflowSourceRegistryApi | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def trace_range(*, start: int, limit: int) -> TraceRange:
|
def trace_range(*, start: int, limit: int) -> TraceRange:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .methods_capabilities import register_methods as register_capability_method
|
|||||||
from .methods_deployments import register_methods as register_deployment_methods
|
from .methods_deployments import register_methods as register_deployment_methods
|
||||||
from .methods_drafts import register_methods as register_draft_methods
|
from .methods_drafts import register_methods as register_draft_methods
|
||||||
from .methods_runs import register_methods as register_run_methods
|
from .methods_runs import register_methods as register_run_methods
|
||||||
|
from .methods_source_registry import register_methods as register_source_registry_methods
|
||||||
from .methods_sources import register_methods as register_source_methods
|
from .methods_sources import register_methods as register_source_methods
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc
|
|||||||
register_deployment_methods(entrypoint, server)
|
register_deployment_methods(entrypoint, server)
|
||||||
register_run_methods(entrypoint, server)
|
register_run_methods(entrypoint, server)
|
||||||
register_source_methods(entrypoint, server)
|
register_source_methods(entrypoint, server)
|
||||||
|
register_source_registry_methods(entrypoint, server)
|
||||||
register_admin_methods(entrypoint, server)
|
register_admin_methods(entrypoint, server)
|
||||||
|
|
||||||
app.bind_entrypoint(entrypoint)
|
app.bind_entrypoint(entrypoint)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .client_capabilities import RpcCapabilityClientMixin
|
|||||||
from .client_deployments import RpcDeploymentClientMixin
|
from .client_deployments import RpcDeploymentClientMixin
|
||||||
from .client_drafts import RpcDraftClientMixin
|
from .client_drafts import RpcDraftClientMixin
|
||||||
from .client_runs import RpcRunClientMixin
|
from .client_runs import RpcRunClientMixin
|
||||||
|
from .client_source_registry import RpcSourceRegistryClientMixin
|
||||||
from .client_sources import RpcSourceAdminClientMixin
|
from .client_sources import RpcSourceAdminClientMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class RpcWorkflowApiClient(
|
|||||||
RpcDeploymentClientMixin,
|
RpcDeploymentClientMixin,
|
||||||
RpcRunClientMixin,
|
RpcRunClientMixin,
|
||||||
RpcSourceAdminClientMixin,
|
RpcSourceAdminClientMixin,
|
||||||
|
RpcSourceRegistryClientMixin,
|
||||||
RpcAdminClientMixin,
|
RpcAdminClientMixin,
|
||||||
):
|
):
|
||||||
"""WorkflowApiSurface implementation backed by JSON-RPC HTTP calls.
|
"""WorkflowApiSurface implementation backed by JSON-RPC HTTP calls.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class RpcSourceRegistryClientMixin:
|
||||||
|
"""JSON-RPC implementation of read-only desired source registry surface methods."""
|
||||||
|
|
||||||
|
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def list_registry_entries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"workflow.admin.source_registry.list",
|
||||||
|
{"cursor": cursor, "limit": limit},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inspect_registry_entry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"workflow.admin.source_registry.inspect",
|
||||||
|
{"source_id": source_id},
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Body
|
||||||
|
import fastapi_jsonrpc as jsonrpc
|
||||||
|
from fastapi_jsonrpc import Params
|
||||||
|
|
||||||
|
from wf_server import WorkflowServer
|
||||||
|
|
||||||
|
from .errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||||
|
from .models import InspectRegistryEntryParams, ListRegistryEntriesParams
|
||||||
|
|
||||||
|
|
||||||
|
def register_methods(
|
||||||
|
entrypoint: jsonrpc.Entrypoint,
|
||||||
|
server: WorkflowServer,
|
||||||
|
) -> None:
|
||||||
|
"""Register read-only desired source registry JSON-RPC methods."""
|
||||||
|
|
||||||
|
@entrypoint.method(
|
||||||
|
name="workflow.admin.source_registry.list",
|
||||||
|
errors=[WorkflowRpcError],
|
||||||
|
)
|
||||||
|
async def workflow_admin_source_registry_list(
|
||||||
|
params: ListRegistryEntriesParams = Body(
|
||||||
|
default_factory=ListRegistryEntriesParams,
|
||||||
|
),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if server.source_registry_admin is None:
|
||||||
|
raise WorkflowRpcError(
|
||||||
|
data={
|
||||||
|
"code": "source_registry_unavailable",
|
||||||
|
"message": "source registry admin reads are not available for this server",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await server.source_registry_admin.list_registry_entries(
|
||||||
|
cursor=params.cursor,
|
||||||
|
limit=params.limit,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
|
@entrypoint.method(
|
||||||
|
name="workflow.admin.source_registry.inspect",
|
||||||
|
errors=[WorkflowRpcError],
|
||||||
|
)
|
||||||
|
async def workflow_admin_source_registry_inspect(
|
||||||
|
params: InspectRegistryEntryParams = Params(...), # type: ignore[reportArgumentType]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if server.source_registry_admin is None:
|
||||||
|
raise WorkflowRpcError(
|
||||||
|
data={
|
||||||
|
"code": "source_registry_unavailable",
|
||||||
|
"message": "source registry admin reads are not available for this server",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await server.source_registry_admin.inspect_registry_entry(
|
||||||
|
source_id=params.source_id,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
@@ -179,3 +179,12 @@ class ResumeRunParams(RpcParamsModel):
|
|||||||
resume_payload: dict[str, Any] = Field(default_factory=dict)
|
resume_payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
resume_outcome: str = Field(default="submitted", min_length=1)
|
resume_outcome: str = Field(default="submitted", min_length=1)
|
||||||
trace_range: TraceRangeParams | None = None
|
trace_range: TraceRangeParams | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListRegistryEntriesParams(RpcParamsModel):
|
||||||
|
cursor: str | None = Field(default=None)
|
||||||
|
limit: int = Field(default=50, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class InspectRegistryEntryParams(RpcParamsModel):
|
||||||
|
source_id: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_api import WorkflowSourceRegistryApi, WorkflowSourceRegistrySurface
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FakeRegistryEntry:
|
||||||
|
id: str
|
||||||
|
kind: str = "mcp"
|
||||||
|
enabled: bool = True
|
||||||
|
provider: str = ""
|
||||||
|
account: str = ""
|
||||||
|
profile: str | None = None
|
||||||
|
transport: dict[str, Any] = field(default_factory=lambda: {"kind": "stdio"})
|
||||||
|
auth_ref: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistryProvider:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
entries: list[FakeRegistryEntry] | None = None,
|
||||||
|
config_ids: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._entries = entries or []
|
||||||
|
self._config_ids = config_ids or set()
|
||||||
|
|
||||||
|
def list_registry_entries(self) -> list[FakeRegistryEntry]:
|
||||||
|
return self._entries
|
||||||
|
|
||||||
|
def config_source_ids(self) -> set[str]:
|
||||||
|
return self._config_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _api(
|
||||||
|
*entries: FakeRegistryEntry,
|
||||||
|
config_ids: set[str] | None = None,
|
||||||
|
) -> WorkflowSourceRegistryApi:
|
||||||
|
return WorkflowSourceRegistryApi(
|
||||||
|
provider=FakeRegistryProvider(list(entries), config_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_returns_compact_summaries_in_id_order() -> None:
|
||||||
|
api = _api(
|
||||||
|
FakeRegistryEntry(id="zeta.work", provider="zeta", account="work"),
|
||||||
|
FakeRegistryEntry(id="alpha.personal", provider="alpha", account="personal"),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(api.list_registry_entries())
|
||||||
|
|
||||||
|
assert payload["total"] == 2
|
||||||
|
assert [e["id"] for e in payload["entries"]] == ["alpha.personal", "zeta.work"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_summary_fields() -> None:
|
||||||
|
api = _api(
|
||||||
|
FakeRegistryEntry(
|
||||||
|
id="github.work",
|
||||||
|
provider="github",
|
||||||
|
account="work",
|
||||||
|
profile="dev",
|
||||||
|
transport={"kind": "stdio", "command": "npx"},
|
||||||
|
auth_ref="github.work",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(api.list_registry_entries())
|
||||||
|
entry = payload["entries"][0]
|
||||||
|
|
||||||
|
assert entry["id"] == "github.work"
|
||||||
|
assert entry["kind"] == "mcp"
|
||||||
|
assert entry["enabled"] is True
|
||||||
|
assert entry["provider"] == "github"
|
||||||
|
assert entry["account"] == "work"
|
||||||
|
assert entry["profile"] == "dev"
|
||||||
|
assert entry["transport_kind"] == "stdio"
|
||||||
|
assert entry["auth_ref"] == "github.work"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pagination() -> None:
|
||||||
|
api = _api(
|
||||||
|
FakeRegistryEntry(id="a"),
|
||||||
|
FakeRegistryEntry(id="b"),
|
||||||
|
FakeRegistryEntry(id="c"),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.run(api.list_registry_entries(limit=2))
|
||||||
|
second = asyncio.run(api.list_registry_entries(cursor=first["next_cursor"], limit=2))
|
||||||
|
|
||||||
|
assert [e["id"] for e in first["entries"]] == ["a", "b"]
|
||||||
|
assert first["next_cursor"] == "2"
|
||||||
|
assert [e["id"] for e in second["entries"]] == ["c"]
|
||||||
|
assert second["next_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shadowed_by_config() -> None:
|
||||||
|
api = _api(
|
||||||
|
FakeRegistryEntry(id="github.work"),
|
||||||
|
FakeRegistryEntry(id="slack.personal"),
|
||||||
|
config_ids={"github.work"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(api.list_registry_entries())
|
||||||
|
|
||||||
|
gh = next(e for e in payload["entries"] if e["id"] == "github.work")
|
||||||
|
sl = next(e for e in payload["entries"] if e["id"] == "slack.personal")
|
||||||
|
assert gh["shadowed_by_config"] is True
|
||||||
|
assert sl["shadowed_by_config"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_returns_full_entry_and_shadow_flag() -> None:
|
||||||
|
api = _api(
|
||||||
|
FakeRegistryEntry(
|
||||||
|
id="github.work",
|
||||||
|
provider="github",
|
||||||
|
account="work",
|
||||||
|
transport={"kind": "stdio", "command": "npx", "args": [], "env": {}},
|
||||||
|
auth_ref="github.work",
|
||||||
|
),
|
||||||
|
config_ids={"github.work"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(api.inspect_registry_entry(source_id="github.work"))
|
||||||
|
|
||||||
|
assert payload["entry"]["id"] == "github.work"
|
||||||
|
assert payload["entry"]["transport"]["kind"] == "stdio"
|
||||||
|
assert payload["shadowed_by_config"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_unknown_raises_key_error() -> None:
|
||||||
|
api = _api(FakeRegistryEntry(id="github.work"))
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="unknown registry source 'missing'"):
|
||||||
|
asyncio.run(api.inspect_registry_entry(source_id="missing"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_satisfies_surface_protocol() -> None:
|
||||||
|
api: WorkflowSourceRegistrySurface = _api(FakeRegistryEntry(id="x"))
|
||||||
|
|
||||||
|
assert api is not None
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from wf_cli.app import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_registry_help_exists() -> None:
|
||||||
|
result = runner.invoke(app, ["admin", "registry", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "list" in result.output
|
||||||
|
assert "inspect" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_registry_list_help_exists() -> None:
|
||||||
|
result = runner.invoke(app, ["admin", "registry", "list", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "--limit" in result.output
|
||||||
|
assert "--cursor" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_registry_inspect_help_exists() -> None:
|
||||||
|
result = runner.invoke(app, ["admin", "registry", "inspect", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "SOURCE_ID" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_registry_list_local_static_returns_unavailable(tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "wf_mcp.config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"client": {"target": {"kind": "local"}},
|
||||||
|
"server": {"store": {"kind": "filesystem", "root": str(tmp_path / "store")}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["--config", str(config_path), "admin", "registry", "list"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "not available" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_registry_inspect_local_static_returns_unavailable(tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "wf_mcp.config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"client": {"target": {"kind": "local"}},
|
||||||
|
"server": {"store": {"kind": "filesystem", "root": str(tmp_path / "store")}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["--config", str(config_path), "admin", "registry", "inspect", "github.work"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "not available" in result.output
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from wf_mcp.broker.service.source_registry_admin import SourceRegistryAdminProvider
|
||||||
|
from wf_mcp.models import ConnectionConfig
|
||||||
|
from wf_mcp.source_registry import (
|
||||||
|
FileSourceRegistryStore,
|
||||||
|
McpSourceRegistryEntry,
|
||||||
|
SourceRegistryFile,
|
||||||
|
StdioSourceTransport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_with_entries(root: Path, *entries: McpSourceRegistryEntry) -> FileSourceRegistryStore:
|
||||||
|
store = FileSourceRegistryStore(root)
|
||||||
|
store.save_registry(SourceRegistryFile(sources=list(entries)))
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(source_id: str, *, provider: str = "github", account: str = "work") -> McpSourceRegistryEntry:
|
||||||
|
return McpSourceRegistryEntry(
|
||||||
|
id=source_id,
|
||||||
|
provider=provider,
|
||||||
|
account=account,
|
||||||
|
transport=StdioSourceTransport(command="npx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_lists_entries_from_store(tmp_path: Path) -> None:
|
||||||
|
store = _store_with_entries(
|
||||||
|
tmp_path / "reg",
|
||||||
|
_entry("alpha.work"),
|
||||||
|
_entry("zeta.personal", provider="zeta", account="personal"),
|
||||||
|
)
|
||||||
|
provider = SourceRegistryAdminProvider(source_registry_store=store)
|
||||||
|
|
||||||
|
entries = provider.list_registry_entries()
|
||||||
|
|
||||||
|
assert len(entries) == 2
|
||||||
|
ids = {getattr(e, "id", getattr(e, "get", lambda k: None)("id")) for e in entries}
|
||||||
|
assert ids == {"alpha.work", "zeta.personal"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_reports_config_shadowed_ids(tmp_path: Path) -> None:
|
||||||
|
store = _store_with_entries(tmp_path / "reg", _entry("github.work"))
|
||||||
|
connections = [
|
||||||
|
ConnectionConfig(id="github.work", server="github", account="work"),
|
||||||
|
ConnectionConfig(id="other.personal", server="other", account="personal"),
|
||||||
|
]
|
||||||
|
provider = SourceRegistryAdminProvider(
|
||||||
|
source_registry_store=store,
|
||||||
|
config_connections=connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
shadowed = provider.config_source_ids()
|
||||||
|
|
||||||
|
assert shadowed == {"github.work", "other.personal"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_empty_store(tmp_path: Path) -> None:
|
||||||
|
store = FileSourceRegistryStore(tmp_path / "reg")
|
||||||
|
provider = SourceRegistryAdminProvider(source_registry_store=store)
|
||||||
|
|
||||||
|
entries = provider.list_registry_entries()
|
||||||
|
shadowed = provider.config_source_ids()
|
||||||
|
|
||||||
|
assert entries == []
|
||||||
|
assert shadowed == set()
|
||||||
@@ -157,3 +157,9 @@ def test_local_static_server_inspects_and_reads_bounded_trace(tmp_path) -> None:
|
|||||||
assert trace["trace_start"] == 0
|
assert trace["trace_start"] == 0
|
||||||
assert trace["trace_limit"] == 1
|
assert trace["trace_limit"] == 1
|
||||||
assert len(trace["trace"]) == 1
|
assert len(trace["trace"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_static_server_has_no_source_registry_admin(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
|
||||||
|
assert server.source_registry_admin is None
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FakeRegistryEntry:
|
||||||
|
id: str
|
||||||
|
kind: str = "mcp"
|
||||||
|
enabled: bool = True
|
||||||
|
provider: str = "github"
|
||||||
|
account: str = "work"
|
||||||
|
profile: str | None = None
|
||||||
|
transport: dict[str, str] | None = None
|
||||||
|
auth_ref: str | None = "github.work"
|
||||||
|
metadata: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistryProvider:
|
||||||
|
def list_registry_entries(self) -> list[FakeRegistryEntry]:
|
||||||
|
return [
|
||||||
|
FakeRegistryEntry(
|
||||||
|
id="github.work",
|
||||||
|
transport={"kind": "stdio", "command": "npx"},
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def config_source_ids(self) -> set[str]:
|
||||||
|
return {"github.work"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _rpc(
|
||||||
|
client: httpx.AsyncClient, method: str, params: dict
|
||||||
|
) -> dict:
|
||||||
|
response = await client.post(
|
||||||
|
"/rpc",
|
||||||
|
json={"jsonrpc": "2.0", "id": "test", "method": method, "params": params},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_source_registry_list_unavailable_on_local_static(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
payload = await _rpc(
|
||||||
|
client, "workflow.admin.source_registry.list", {"limit": 10}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" in payload
|
||||||
|
assert payload["error"]["data"]["code"] == "source_registry_unavailable"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_source_registry_inspect_unavailable_on_local_static(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
payload = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.admin.source_registry.inspect",
|
||||||
|
{"source_id": "github.work"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" in payload
|
||||||
|
assert payload["error"]["data"]["code"] == "source_registry_unavailable"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_source_registry_methods_return_registry_payloads(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
server = replace(
|
||||||
|
build_local_static_workflow_server(tmp_path / "store"),
|
||||||
|
source_registry_admin=WorkflowSourceRegistryApi(
|
||||||
|
provider=FakeRegistryProvider()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
listed = await _rpc(
|
||||||
|
client, "workflow.admin.source_registry.list", {"limit": 10}
|
||||||
|
)
|
||||||
|
inspected = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.admin.source_registry.inspect",
|
||||||
|
{"source_id": "github.work"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert listed["result"]["entries"][0]["id"] == "github.work"
|
||||||
|
assert listed["result"]["entries"][0]["shadowed_by_config"] is True
|
||||||
|
assert inspected["result"]["entry"]["transport"]["kind"] == "stdio"
|
||||||
|
assert inspected["result"]["shadowed_by_config"] is True
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_client_source_registry_calls_correct_methods(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as http_client:
|
||||||
|
client = RpcWorkflowApiClient(
|
||||||
|
url="http://test/rpc",
|
||||||
|
timeout_seconds=5,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.list_registry_entries(limit=5)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
list_error = str(exc)
|
||||||
|
else:
|
||||||
|
list_error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.inspect_registry_entry(source_id="x")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
inspect_error = str(exc)
|
||||||
|
else:
|
||||||
|
inspect_error = None
|
||||||
|
|
||||||
|
assert list_error is not None
|
||||||
|
assert "source registry admin reads are not available" in list_error
|
||||||
|
assert inspect_error is not None
|
||||||
|
assert "source registry admin reads are not available" in inspect_error
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
Reference in New Issue
Block a user