feat: add source admin api surface
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user