feat: add source admin api surface

This commit is contained in:
lda
2026-06-03 21:40:06 +07:00 Verified
parent 08d0203836
commit 24ca91afb1
9 changed files with 248 additions and 11 deletions
+4
View File
@@ -100,6 +100,10 @@ implementation state.
public `RpcWorkflowApiClient` still satisfies `WorkflowApiSurface`, while public `RpcWorkflowApiClient` still satisfies `WorkflowApiSurface`, while
client methods and server JSON-RPC registrations live in focused client methods and server JSON-RPC registrations live in focused
capability, draft, artifact, deployment, and run modules. capability, draft, artifact, deployment, and run modules.
- Completed: read-only source inventory now has a protocol-neutral
`WorkflowSourceAdminApi` / `WorkflowSourceAdminSurface`; MCP admin source
tools delegate through it while connection/raw MCP operations remain
broker-owned.
5. **CLI/API alignment** 5. **CLI/API alignment**
- Completed for the basic lifecycle: selected `wf` commands can target local - Completed for the basic lifecycle: selected `wf` commands can target local
@@ -80,11 +80,10 @@ surface, or plain local CLI utilities.
## Next Slices ## Next Slices
1. **Server source/admin operations** 1. **Source/admin transport and CLI commands**
- Add a protocol-neutral admin/source surface for source listing, source - Build JSON-RPC methods and `wf source ...` commands over
health, and dynamic source registration. `WorkflowSourceAdminSurface`.
- Do not overload `WorkflowApiSurface` if the operation is not a workflow - Keep mutation out until the store-backed source registry is designed.
lifecycle operation.
2. **Store-backed source registry** 2. **Store-backed source registry**
- Config can bootstrap sources, but server-owned dynamic source changes - Config can bootstrap sources, but server-owned dynamic source changes
+4
View File
@@ -18,6 +18,7 @@ from .next_actions import NextActionPatchExample, NextActionTool, NextActions
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
from .runs import WorkflowRunApi from .runs import WorkflowRunApi
from .service import WorkflowApi from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi
from .surface import ( from .surface import (
WorkflowApiSurface, WorkflowApiSurface,
WorkflowArtifactSurface, WorkflowArtifactSurface,
@@ -25,6 +26,7 @@ from .surface import (
WorkflowDeploymentSurface, WorkflowDeploymentSurface,
WorkflowDraftSurface, WorkflowDraftSurface,
WorkflowRunSurface, WorkflowRunSurface,
WorkflowSourceAdminSurface,
) )
from .wrapper_hints import ( from .wrapper_hints import (
MissingDecision, MissingDecision,
@@ -87,6 +89,8 @@ __all__ = [
"WorkflowRuntimeRunner", "WorkflowRuntimeRunner",
"WorkflowRunApi", "WorkflowRunApi",
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface",
"WorkflowSpecProvider", "WorkflowSpecProvider",
"WorkflowSurfaceCapabilityId", "WorkflowSurfaceCapabilityId",
"WrapperAuthoringHints", "WrapperAuthoringHints",
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any
from wf_platform import page_items
from .operation_context import WorkflowOperationContext
class WorkflowSourceAdminApi:
"""Read-only protocol-neutral source inventory operations.
This is a sibling to WorkflowApi, not part of WorkflowApiSurface, because
source administration is server/platform management rather than workflow
lifecycle execution.
"""
def __init__(self, context: WorkflowOperationContext) -> None:
self.context = context
async def list_sources(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
summaries = [
source.as_status().model_dump(mode="json")
for source in sorted(
self.context.specs.capability_sources.values(),
key=lambda source: source.id,
)
]
page = page_items(summaries, cursor=cursor, limit=limit)
return {
"sources": list(page.items),
"next_cursor": page.next_cursor,
"total": page.total,
}
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
try:
source = self.context.specs.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
return source.as_inventory().model_dump(mode="json")
+18
View File
@@ -199,6 +199,23 @@ class WorkflowApiSurface(
"""Public workflow operation surface shared by local and remote adapters.""" """Public workflow operation surface shared by local and remote adapters."""
class WorkflowSourceAdminSurface(Protocol):
"""Read-only source/admin methods exposed by platform frontends."""
async def list_sources(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]: ...
async def inspect_source(
self,
*,
source_id: str,
) -> dict[str, Any]: ...
__all__ = [ __all__ = [
"WorkflowApiSurface", "WorkflowApiSurface",
"WorkflowArtifactSurface", "WorkflowArtifactSurface",
@@ -206,4 +223,5 @@ __all__ = [
"WorkflowDeploymentSurface", "WorkflowDeploymentSurface",
"WorkflowDraftSurface", "WorkflowDraftSurface",
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowSourceAdminSurface",
] ]
+9 -4
View File
@@ -1,7 +1,9 @@
from dataclasses import asdict from dataclasses import asdict
from typing import Any from typing import Any
from wf_api import WorkflowSourceAdminApi, WorkflowSourceAdminSurface
from wf_mcp.broker.service import WfMcpService from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service
from wf_mcp.shared.errors import error_payload from wf_mcp.shared.errors import error_payload
@@ -10,6 +12,9 @@ class BrokerAdminHandlers:
def __init__(self, service: WfMcpService) -> None: def __init__(self, service: WfMcpService) -> None:
self.service = service self.service = service
self.sources: WorkflowSourceAdminSurface = WorkflowSourceAdminApi(
context_from_service(service)
)
def list_connections(self) -> list[dict[str, Any]]: def list_connections(self) -> list[dict[str, Any]]:
return [ return [
@@ -49,16 +54,16 @@ class BrokerAdminHandlers:
def get_planner_catalog(self) -> dict[str, Any]: def get_planner_catalog(self) -> dict[str, Any]:
return self.service.get_planner_catalog().as_payload() return self.service.get_planner_catalog().as_payload()
def list_sources( async def list_sources(
self, self,
*, *,
cursor: str | None = None, cursor: str | None = None,
limit: int = 50, limit: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
return self.service.list_source_summaries(cursor=cursor, limit=limit) return await self.sources.list_sources(cursor=cursor, limit=limit)
def inspect_source(self, source_id: str) -> dict[str, Any]: async def inspect_source(self, source_id: str) -> dict[str, Any]:
return self.service.inspect_source(source_id) return await self.sources.inspect_source(source_id=source_id)
async def read_broker_resource(self, qualified_name: str) -> dict[str, Any]: async def read_broker_resource(self, qualified_name: str) -> dict[str, Any]:
return await self.service.read_resource(qualified_name) return await self.service.read_resource(qualified_name)
+2 -2
View File
@@ -107,7 +107,7 @@ def register_service_admin_tools(
), ),
] = 50, ] = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
return handlers.list_sources(cursor=cursor, limit=limit) return await handlers.list_sources(cursor=cursor, limit=limit)
@server.tool( @server.tool(
name=name("inspect_source"), name=name("inspect_source"),
@@ -125,7 +125,7 @@ def register_service_admin_tools(
), ),
], ],
) -> dict[str, Any]: ) -> dict[str, Any]:
return handlers.inspect_source(source_id) return await handlers.inspect_source(source_id)
@server.tool( @server.tool(
name=name("read_resource"), name=name("read_resource"),
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from wf_api import WorkflowSourceAdminApi, WorkflowSourceAdminSurface
from wf_api.models import RawWorkflowPlan
from wf_api.operation_context import WorkflowOperationContext
from wf_api.saved_subgraphs import SavedSubgraphTree
from wf_artifacts import WorkflowArtifact, WorkflowDeployment
from wf_authoring import NodeSpec
from wf_core import RunState
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
SourcePermissions,
SourceVisibility,
)
class DummyEvents:
def record_event(self, event: object) -> None:
pass
def record_workflow_event(
self,
event_type: str,
*,
capability_id: str,
payload: dict[str, Any],
) -> None:
pass
class DummyRuntime:
async def run_workflow_from_plan(
self,
plan: RawWorkflowPlan,
workflow_input: dict[str, Any],
deployment: WorkflowDeployment | None = None,
artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState:
raise AssertionError("source admin tests must not run workflows")
async def resume_workflow_from_plan(
self,
plan: RawWorkflowPlan,
run: RunState,
*,
resume_payload: dict[str, Any],
resume_outcome: str,
deployment: WorkflowDeployment | None = None,
artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState:
raise AssertionError("source admin tests must not resume workflows")
class StaticSpecProvider:
def __init__(self, sources: dict[str, CapabilitySource]) -> None:
self._sources = sources
@property
def capability_sources(self) -> dict[str, CapabilitySource]:
return self._sources
def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
raise KeyError(f"unknown capability {qualified_name!r}")
def _api(*sources: CapabilitySource) -> WorkflowSourceAdminApi:
provider = StaticSpecProvider({source.id: source for source in sources})
return WorkflowSourceAdminApi(
WorkflowOperationContext(
artifact_store=None,
draft_workspace_store=None,
run_store=None,
events=DummyEvents(),
specs=provider,
runtime=DummyRuntime(),
live_sources=None,
)
)
def _source(source_id: str, *, enabled: bool = True) -> CapabilitySource:
return CapabilitySource(
id=source_id,
kind="connection",
enabled=enabled,
capabilities=CapabilityBuckets(),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(calls_upstream=True),
description=f"{source_id} source",
)
def test_source_admin_lists_compact_sources_in_id_order() -> None:
api = _api(_source("zeta.personal"), _source("alpha.personal", enabled=False))
payload = asyncio.run(api.list_sources(limit=10))
assert payload["total"] == 2
assert payload["next_cursor"] is None
assert [source["id"] for source in payload["sources"]] == [
"alpha.personal",
"zeta.personal",
]
assert payload["sources"][0]["enabled"] is False
assert payload["sources"][1]["description"] == "zeta.personal source"
def test_source_admin_pages_sources() -> None:
api = _api(_source("a"), _source("b"), _source("c"))
first = asyncio.run(api.list_sources(limit=2))
second = asyncio.run(api.list_sources(cursor=first["next_cursor"], limit=2))
assert [source["id"] for source in first["sources"]] == ["a", "b"]
assert first["next_cursor"] == "2"
assert [source["id"] for source in second["sources"]] == ["c"]
assert second["next_cursor"] is None
def test_source_admin_inspects_full_source_inventory() -> None:
api = _api(_source("demo.personal"))
payload = asyncio.run(api.inspect_source(source_id="demo.personal"))
assert payload["id"] == "demo.personal"
assert payload["kind"] == "connection"
assert payload["description"] == "demo.personal source"
assert payload["visibility"]["planner"] is True
assert payload["permissions"]["calls_upstream"] is True
def test_source_admin_inspect_unknown_source_raises_clear_key_error() -> None:
api = _api(_source("demo.personal"))
with pytest.raises(KeyError, match="unknown source 'missing.source'"):
asyncio.run(api.inspect_source(source_id="missing.source"))
def test_source_admin_api_satisfies_surface_protocol() -> None:
api: WorkflowSourceAdminSurface = _api(_source("demo.personal"))
assert api is not None
+7
View File
@@ -26,6 +26,13 @@ def test_broker_admin_handlers_list_connections_and_events() -> None:
assert events[0]["kind"] == "connection_registered" assert events[0]["kind"] == "connection_registered"
assert events[0]["connection_id"] == "demo.personal" assert events[0]["connection_id"] == "demo.personal"
sources = _run(handlers.list_sources(limit=100))
source_ids = {source["id"] for source in sources["sources"]}
assert "wf.std" in source_ids
assert "wf.admin" in source_ids
assert sources["total"] >= 2
def test_broker_admin_handlers_report_failed_refresh_payload() -> None: def test_broker_admin_handlers_report_failed_refresh_payload() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "admin_refresh_store")) service = WfMcpService(store=FileStore(local_temp_root() / "admin_refresh_store"))