feat: type source discovery results
This commit is contained in:
@@ -72,10 +72,10 @@
|
|||||||
complete OpenRPC document for all 70 registered methods. Request payloads
|
complete OpenRPC document for all 70 registered methods. Request payloads
|
||||||
retain useful Pydantic schemas, so OpenRPC is a viable transport input.
|
retain useful Pydantic schemas, so OpenRPC is a viable transport input.
|
||||||
- Typed-result slices now give `workflow.health`, all artifact, deployment,
|
- Typed-result slices now give `workflow.health`, all artifact, deployment,
|
||||||
and run operations, every persisted draft-workspace operation, and the
|
and run operations, every persisted draft-workspace operation, and both the
|
||||||
capability discovery/call surface named transport-neutral result schemas:
|
capability and source-discovery surfaces named transport-neutral result
|
||||||
50 of 70 methods. The remaining 20 success results still collapse to generic
|
schemas: 53 of 70 methods. The remaining 17 success results still collapse
|
||||||
objects across stateless draft patch/validate, source discovery, and
|
to generic objects across stateless draft patch/validate and the
|
||||||
source-registry/admin operations. Continue introducing operation result DTOs
|
source-registry/admin operations. Continue introducing operation result DTOs
|
||||||
before adopting generated TypeScript contracts.
|
before adopting generated TypeScript contracts.
|
||||||
- The stock `@open-rpc/generator` TypeScript client is not suitable here. It
|
- The stock `@open-rpc/generator` TypeScript client is not suitable here. It
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ from .runs import (
|
|||||||
TraceEntryPayload,
|
TraceEntryPayload,
|
||||||
WorkflowRefPayload,
|
WorkflowRefPayload,
|
||||||
)
|
)
|
||||||
|
from .sources import (
|
||||||
|
InspectSourceResult,
|
||||||
|
ListSourcesResult,
|
||||||
|
NodeSpecInventoryPayload,
|
||||||
|
ReducerInventoryPayload,
|
||||||
|
SourceAuthDiagnosisPayload,
|
||||||
|
SourceCapabilityHasMorePayload,
|
||||||
|
SourceCapabilityInventoryPayload,
|
||||||
|
SourceCapabilityPreviewPayload,
|
||||||
|
SourceCatalogDiagnosisPayload,
|
||||||
|
SourceDiagnosisResult,
|
||||||
|
SourceDiagnosticsUnavailablePayload,
|
||||||
|
SourcePermissionsPayload,
|
||||||
|
SourcePolicyPayload,
|
||||||
|
SourceStatusPayload,
|
||||||
|
SourceTransportDiagnosisPayload,
|
||||||
|
SourceVisibilityPayload,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ArtifactVersionPayload",
|
"ArtifactVersionPayload",
|
||||||
@@ -102,6 +120,7 @@ __all__ = [
|
|||||||
"InterruptRoutePayload",
|
"InterruptRoutePayload",
|
||||||
"InvalidDraftResult",
|
"InvalidDraftResult",
|
||||||
"InspectCapabilityResult",
|
"InspectCapabilityResult",
|
||||||
|
"InspectSourceResult",
|
||||||
"JsonObject",
|
"JsonObject",
|
||||||
"JsonProjector",
|
"JsonProjector",
|
||||||
"JsonSchema",
|
"JsonSchema",
|
||||||
@@ -110,12 +129,15 @@ __all__ = [
|
|||||||
"ListArtifactsResult",
|
"ListArtifactsResult",
|
||||||
"ListCapabilitiesResult",
|
"ListCapabilitiesResult",
|
||||||
"ListRunsResult",
|
"ListRunsResult",
|
||||||
|
"ListSourcesResult",
|
||||||
"NextActionPatchExamplePayload",
|
"NextActionPatchExamplePayload",
|
||||||
"NextActionsPayload",
|
"NextActionsPayload",
|
||||||
"NodeSpecCapabilityDetail",
|
"NodeSpecCapabilityDetail",
|
||||||
"NodeSpecCapabilitySummary",
|
"NodeSpecCapabilitySummary",
|
||||||
|
"NodeSpecInventoryPayload",
|
||||||
"PageMetadataPayload",
|
"PageMetadataPayload",
|
||||||
"RawWorkflowPlan",
|
"RawWorkflowPlan",
|
||||||
|
"ReducerInventoryPayload",
|
||||||
"ResumeReadiness",
|
"ResumeReadiness",
|
||||||
"RunResult",
|
"RunResult",
|
||||||
"RunStatus",
|
"RunStatus",
|
||||||
@@ -126,6 +148,18 @@ __all__ = [
|
|||||||
"SavedDraftArtifactResult",
|
"SavedDraftArtifactResult",
|
||||||
"SaveDeploymentResult",
|
"SaveDeploymentResult",
|
||||||
"SourceBindingPayload",
|
"SourceBindingPayload",
|
||||||
|
"SourceAuthDiagnosisPayload",
|
||||||
|
"SourceCapabilityHasMorePayload",
|
||||||
|
"SourceCapabilityInventoryPayload",
|
||||||
|
"SourceCapabilityPreviewPayload",
|
||||||
|
"SourceCatalogDiagnosisPayload",
|
||||||
|
"SourceDiagnosisResult",
|
||||||
|
"SourceDiagnosticsUnavailablePayload",
|
||||||
|
"SourcePermissionsPayload",
|
||||||
|
"SourcePolicyPayload",
|
||||||
|
"SourceStatusPayload",
|
||||||
|
"SourceTransportDiagnosisPayload",
|
||||||
|
"SourceVisibilityPayload",
|
||||||
"TraceRange",
|
"TraceRange",
|
||||||
"TraceEntryPayload",
|
"TraceEntryPayload",
|
||||||
"UnsavedDraftArtifactResult",
|
"UnsavedDraftArtifactResult",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, NotRequired, TypedDict
|
||||||
|
|
||||||
|
from pydantic import ConfigDict, with_config
|
||||||
|
|
||||||
|
from .artifacts import CapabilityRefPayload
|
||||||
|
from .common import (
|
||||||
|
DependencyDiagnosticPayload,
|
||||||
|
JsonObject,
|
||||||
|
PageMetadataPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceVisibilityPayload(TypedDict):
|
||||||
|
"""JSON projection of source visibility flags."""
|
||||||
|
|
||||||
|
planner: bool
|
||||||
|
client: bool
|
||||||
|
admin_dashboard: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePermissionsPayload(TypedDict):
|
||||||
|
"""JSON projection of source permission flags."""
|
||||||
|
|
||||||
|
safe_for_workflow: bool
|
||||||
|
calls_upstream: bool
|
||||||
|
mutates_config: bool
|
||||||
|
mutates_auth: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePolicyPayload(TypedDict):
|
||||||
|
"""JSON projection of deployment-binding policy for one source."""
|
||||||
|
|
||||||
|
platform: bool
|
||||||
|
binding_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCapabilityPreviewPayload(TypedDict):
|
||||||
|
"""Small sorted capability-name sample used by compact source rows."""
|
||||||
|
|
||||||
|
tools: list[str]
|
||||||
|
node_specs: list[str]
|
||||||
|
reducers: list[str]
|
||||||
|
prompts: list[str]
|
||||||
|
resources: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCapabilityHasMorePayload(TypedDict):
|
||||||
|
"""Whether each compact capability preview omitted owned names."""
|
||||||
|
|
||||||
|
tools: bool
|
||||||
|
node_specs: bool
|
||||||
|
reducers: bool
|
||||||
|
prompts: bool
|
||||||
|
resources: bool
|
||||||
|
|
||||||
|
|
||||||
|
class NodeSpecInventoryPayload(TypedDict):
|
||||||
|
"""Serializable executable contract owned by one capability source."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
outcomes: list[str]
|
||||||
|
input_schema: JsonObject
|
||||||
|
output_schema: JsonObject
|
||||||
|
is_async: bool
|
||||||
|
accepts_context: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ReducerInventoryPayload(TypedDict):
|
||||||
|
"""Serializable pure-reducer contract owned by one capability source."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
ref: CapabilityRefPayload
|
||||||
|
description: str | None
|
||||||
|
config_schema: JsonObject
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCapabilityInventoryPayload(TypedDict):
|
||||||
|
"""Capability names and detailed executable contracts owned by a source."""
|
||||||
|
|
||||||
|
tools: list[str]
|
||||||
|
node_specs: list[str]
|
||||||
|
node_spec_details: list[NodeSpecInventoryPayload]
|
||||||
|
reducers: list[str]
|
||||||
|
reducer_details: list[ReducerInventoryPayload]
|
||||||
|
prompts: list[str]
|
||||||
|
resources: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceStatusPayload(TypedDict):
|
||||||
|
"""Compact source metadata returned by source discovery."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: Literal["system", "connection", "python"]
|
||||||
|
enabled: bool
|
||||||
|
visibility: SourceVisibilityPayload
|
||||||
|
permissions: SourcePermissionsPayload
|
||||||
|
policy: SourcePolicyPayload
|
||||||
|
description: str | None
|
||||||
|
tool_count: int
|
||||||
|
node_spec_count: int
|
||||||
|
reducer_count: int
|
||||||
|
prompt_count: int
|
||||||
|
resource_count: int
|
||||||
|
preview: SourceCapabilityPreviewPayload
|
||||||
|
has_more: SourceCapabilityHasMorePayload
|
||||||
|
|
||||||
|
|
||||||
|
class ListSourcesResult(PageMetadataPayload):
|
||||||
|
"""Cursor-paged compact source discovery result."""
|
||||||
|
|
||||||
|
sources: list[SourceStatusPayload]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceTransportDiagnosisPayload(TypedDict):
|
||||||
|
"""Known transport-health fields reported by a diagnostics provider."""
|
||||||
|
|
||||||
|
kind: NotRequired[str | None]
|
||||||
|
configured: NotRequired[bool]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceAuthDiagnosisPayload(TypedDict):
|
||||||
|
"""Known auth-health fields reported by a diagnostics provider."""
|
||||||
|
|
||||||
|
auth_ref: NotRequired[str | None]
|
||||||
|
record_present: NotRequired[bool | None]
|
||||||
|
scheme: NotRequired[str | None]
|
||||||
|
transport_supported: NotRequired[bool]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCatalogDiagnosisPayload(TypedDict):
|
||||||
|
"""Known catalog-health fields reported by a diagnostics provider."""
|
||||||
|
|
||||||
|
has_snapshot: NotRequired[bool]
|
||||||
|
fetched_at_epoch_ms: NotRequired[int | None]
|
||||||
|
max_age_seconds: NotRequired[int | None]
|
||||||
|
node_count: NotRequired[int]
|
||||||
|
resource_count: NotRequired[int]
|
||||||
|
prompt_count: NotRequired[int]
|
||||||
|
|
||||||
|
|
||||||
|
@with_config(ConfigDict(extra="allow"))
|
||||||
|
class SourceDiagnosisResult(TypedDict):
|
||||||
|
"""Stable source-diagnostics envelope with provider-specific extensions."""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
status: str
|
||||||
|
diagnostics: list[DependencyDiagnosticPayload]
|
||||||
|
enabled: NotRequired[bool]
|
||||||
|
transport: NotRequired[SourceTransportDiagnosisPayload]
|
||||||
|
auth: NotRequired[SourceAuthDiagnosisPayload]
|
||||||
|
catalog: NotRequired[SourceCatalogDiagnosisPayload]
|
||||||
|
message: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDiagnosticsUnavailablePayload(TypedDict):
|
||||||
|
"""Fallback embedded in inspect results when diagnostics collection fails."""
|
||||||
|
|
||||||
|
status: Literal["error"]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class InspectSourceResult(SourceStatusPayload):
|
||||||
|
"""Full source inventory with optional diagnostics supplied by the runtime."""
|
||||||
|
|
||||||
|
capabilities: SourceCapabilityInventoryPayload
|
||||||
|
diagnostics: NotRequired[
|
||||||
|
SourceDiagnosisResult | SourceDiagnosticsUnavailablePayload
|
||||||
|
]
|
||||||
+45
-22
@@ -1,14 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
from wf_platform import page_items
|
from wf_platform import page_items
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
InspectSourceResult,
|
||||||
|
JsonProjector,
|
||||||
|
ListSourcesResult,
|
||||||
|
SourceDiagnosisResult,
|
||||||
|
SourceDiagnosticsUnavailablePayload,
|
||||||
|
)
|
||||||
from .operation_context import WorkflowOperationContext
|
from .operation_context import WorkflowOperationContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROJECT_SOURCE_DIAGNOSIS = JsonProjector(SourceDiagnosisResult)
|
||||||
|
_PROJECT_DIAGNOSTICS_UNAVAILABLE = JsonProjector(SourceDiagnosticsUnavailablePayload)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowSourceDiagnosticsProvider(Protocol):
|
class WorkflowSourceDiagnosticsProvider(Protocol):
|
||||||
"""Optional source-specific diagnostics provider.
|
"""Optional source-specific diagnostics provider.
|
||||||
@@ -42,7 +52,7 @@ class WorkflowSourceAdminApi:
|
|||||||
*,
|
*,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> ListSourcesResult:
|
||||||
summaries = [
|
summaries = [
|
||||||
source.as_status().model_dump(mode="json")
|
source.as_status().model_dump(mode="json")
|
||||||
for source in sorted(
|
for source in sorted(
|
||||||
@@ -51,13 +61,18 @@ class WorkflowSourceAdminApi:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
page = page_items(summaries, cursor=cursor, limit=limit)
|
page = page_items(summaries, cursor=cursor, limit=limit)
|
||||||
return {
|
# SourceStatus validated every row before model_dump produced these
|
||||||
"sources": list(page.items),
|
# transport dictionaries.
|
||||||
"next_cursor": page.next_cursor,
|
return cast(
|
||||||
"total": page.total,
|
ListSourcesResult,
|
||||||
}
|
{
|
||||||
|
"sources": list(page.items),
|
||||||
|
"next_cursor": page.next_cursor,
|
||||||
|
"total": page.total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
|
async def inspect_source(self, *, source_id: str) -> InspectSourceResult:
|
||||||
try:
|
try:
|
||||||
source = self.context.specs.capability_sources[source_id]
|
source = self.context.specs.capability_sources[source_id]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
@@ -65,29 +80,37 @@ class WorkflowSourceAdminApi:
|
|||||||
payload = source.as_inventory().model_dump(mode="json")
|
payload = source.as_inventory().model_dump(mode="json")
|
||||||
if self.diagnostics is not None:
|
if self.diagnostics is not None:
|
||||||
try:
|
try:
|
||||||
payload["diagnostics"] = self.diagnostics.diagnose_source(source_id)
|
payload["diagnostics"] = _PROJECT_SOURCE_DIAGNOSIS(
|
||||||
|
self.diagnostics.diagnose_source(source_id)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Source diagnostics failed for source_id=%s: %s",
|
"Source diagnostics failed for source_id=%s: %s",
|
||||||
source_id,
|
source_id,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
payload["diagnostics"] = {
|
payload["diagnostics"] = _PROJECT_DIAGNOSTICS_UNAVAILABLE(
|
||||||
"status": "error",
|
{
|
||||||
"message": "Diagnostics unavailable",
|
"status": "error",
|
||||||
}
|
"message": "Diagnostics unavailable",
|
||||||
return payload
|
}
|
||||||
|
)
|
||||||
|
# SourceInventory validated the stable inventory before model_dump;
|
||||||
|
# only the optional provider diagnostics are projected above.
|
||||||
|
return cast(InspectSourceResult, payload)
|
||||||
|
|
||||||
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
|
async def diagnose_source(self, *, source_id: str) -> SourceDiagnosisResult:
|
||||||
try:
|
try:
|
||||||
self.context.specs.capability_sources[source_id]
|
self.context.specs.capability_sources[source_id]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise KeyError(f"unknown source {source_id!r}") from exc
|
raise KeyError(f"unknown source {source_id!r}") from exc
|
||||||
if self.diagnostics is None:
|
if self.diagnostics is None:
|
||||||
return {
|
return _PROJECT_SOURCE_DIAGNOSIS(
|
||||||
"source_id": source_id,
|
{
|
||||||
"status": "unknown",
|
"source_id": source_id,
|
||||||
"diagnostics": [],
|
"status": "unknown",
|
||||||
"message": "No source diagnostics provider is configured.",
|
"diagnostics": [],
|
||||||
}
|
"message": "No source diagnostics provider is configured.",
|
||||||
return self.diagnostics.diagnose_source(source_id)
|
}
|
||||||
|
)
|
||||||
|
return _PROJECT_SOURCE_DIAGNOSIS(self.diagnostics.diagnose_source(source_id))
|
||||||
|
|||||||
@@ -19,15 +19,18 @@ from .models import (
|
|||||||
DeleteDraftWorkspaceResult,
|
DeleteDraftWorkspaceResult,
|
||||||
DraftWorkspaceResult,
|
DraftWorkspaceResult,
|
||||||
InspectCapabilityResult,
|
InspectCapabilityResult,
|
||||||
|
InspectSourceResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
ListCapabilitiesResult,
|
ListCapabilitiesResult,
|
||||||
ListDeploymentsResult,
|
ListDeploymentsResult,
|
||||||
ListDraftWorkspacesResult,
|
ListDraftWorkspacesResult,
|
||||||
ListRunsResult,
|
ListRunsResult,
|
||||||
|
ListSourcesResult,
|
||||||
RunResult,
|
RunResult,
|
||||||
RunTraceResult,
|
RunTraceResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SaveDeploymentResult,
|
SaveDeploymentResult,
|
||||||
|
SourceDiagnosisResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
WorkflowDeploymentPayload,
|
WorkflowDeploymentPayload,
|
||||||
@@ -496,19 +499,19 @@ class WorkflowSourceAdminSurface(Protocol):
|
|||||||
*,
|
*,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, Any]: ...
|
) -> ListSourcesResult: ...
|
||||||
|
|
||||||
async def inspect_source(
|
async def inspect_source(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
source_id: str,
|
source_id: str,
|
||||||
) -> dict[str, Any]: ...
|
) -> InspectSourceResult: ...
|
||||||
|
|
||||||
async def diagnose_source(
|
async def diagnose_source(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
source_id: str,
|
source_id: str,
|
||||||
) -> dict[str, Any]: ...
|
) -> SourceDiagnosisResult: ...
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAdminSurface(Protocol):
|
class WorkflowAdminSurface(Protocol):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import cast
|
||||||
|
|
||||||
|
from wf_api.models import InspectSourceResult, ListSourcesResult, SourceDiagnosisResult
|
||||||
|
|
||||||
from .base import RpcCaller
|
from .base import RpcCaller
|
||||||
|
|
||||||
@@ -13,23 +15,34 @@ class RpcSourceAdminClientMixin:
|
|||||||
*,
|
*,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> ListSourcesResult:
|
||||||
return await self._call(
|
return cast(
|
||||||
"workflow.sources.list",
|
ListSourcesResult,
|
||||||
{
|
await self._call(
|
||||||
"cursor": cursor,
|
"workflow.sources.list",
|
||||||
"limit": limit,
|
{
|
||||||
},
|
"cursor": cursor,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def inspect_source(self: RpcCaller, *, source_id: str) -> dict[str, Any]:
|
async def inspect_source(self: RpcCaller, *, source_id: str) -> InspectSourceResult:
|
||||||
return await self._call(
|
return cast(
|
||||||
"workflow.sources.inspect",
|
InspectSourceResult,
|
||||||
{"source_id": source_id},
|
await self._call(
|
||||||
|
"workflow.sources.inspect",
|
||||||
|
{"source_id": source_id},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def diagnose_source(self: RpcCaller, *, source_id: str) -> dict[str, Any]:
|
async def diagnose_source(
|
||||||
return await self._call(
|
self: RpcCaller, *, source_id: str
|
||||||
"workflow.sources.diagnose",
|
) -> SourceDiagnosisResult:
|
||||||
{"source_id": source_id},
|
return cast(
|
||||||
|
SourceDiagnosisResult,
|
||||||
|
await self._call(
|
||||||
|
"workflow.sources.diagnose",
|
||||||
|
{"source_id": source_id},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
"""Source discovery JSON-RPC method registration.
|
||||||
|
|
||||||
from typing import Any
|
Return annotations stay eagerly evaluated because fastapi-jsonrpc captures them
|
||||||
|
while registering nested handlers for response validation and OpenRPC output.
|
||||||
|
"""
|
||||||
|
|
||||||
import fastapi_jsonrpc as jsonrpc
|
import fastapi_jsonrpc as jsonrpc
|
||||||
|
|
||||||
|
from wf_api.models import InspectSourceResult, ListSourcesResult, SourceDiagnosisResult
|
||||||
from wf_server import WorkflowServer
|
from wf_server import WorkflowServer
|
||||||
|
|
||||||
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||||
@@ -20,7 +23,7 @@ def register_methods(
|
|||||||
@entrypoint.method(name="workflow.sources.list", errors=[WorkflowRpcError])
|
@entrypoint.method(name="workflow.sources.list", errors=[WorkflowRpcError])
|
||||||
async def workflow_sources_list(
|
async def workflow_sources_list(
|
||||||
params: ListSourcesParams = RpcParams(),
|
params: ListSourcesParams = RpcParams(),
|
||||||
) -> dict[str, Any]:
|
) -> ListSourcesResult:
|
||||||
try:
|
try:
|
||||||
return await server.source_admin.list_sources(
|
return await server.source_admin.list_sources(
|
||||||
cursor=params.cursor,
|
cursor=params.cursor,
|
||||||
@@ -32,7 +35,7 @@ def register_methods(
|
|||||||
@entrypoint.method(name="workflow.sources.inspect", errors=[WorkflowRpcError])
|
@entrypoint.method(name="workflow.sources.inspect", errors=[WorkflowRpcError])
|
||||||
async def workflow_sources_inspect(
|
async def workflow_sources_inspect(
|
||||||
params: InspectSourceParams = RpcParams(),
|
params: InspectSourceParams = RpcParams(),
|
||||||
) -> dict[str, Any]:
|
) -> InspectSourceResult:
|
||||||
try:
|
try:
|
||||||
return await server.source_admin.inspect_source(source_id=params.source_id)
|
return await server.source_admin.inspect_source(source_id=params.source_id)
|
||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
@@ -41,7 +44,7 @@ def register_methods(
|
|||||||
@entrypoint.method(name="workflow.sources.diagnose", errors=[WorkflowRpcError])
|
@entrypoint.method(name="workflow.sources.diagnose", errors=[WorkflowRpcError])
|
||||||
async def workflow_sources_diagnose(
|
async def workflow_sources_diagnose(
|
||||||
params: DiagnoseSourceParams = RpcParams(),
|
params: DiagnoseSourceParams = RpcParams(),
|
||||||
) -> dict[str, Any]:
|
) -> SourceDiagnosisResult:
|
||||||
try:
|
try:
|
||||||
return await server.source_admin.diagnose_source(source_id=params.source_id)
|
return await server.source_admin.diagnose_source(source_id=params.source_id)
|
||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
|||||||
@@ -243,6 +243,28 @@ def test_diagnose_source_uses_provider() -> None:
|
|||||||
assert payload["auth"]["record_present"] is True
|
assert payload["auth"]["record_present"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class _ExtendedDiagnostics:
|
||||||
|
def diagnose_source(self, source_id: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"source_id": source_id,
|
||||||
|
"status": "degraded",
|
||||||
|
"diagnostics": [],
|
||||||
|
"provider_latency_ms": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_source_preserves_provider_extensions() -> None:
|
||||||
|
payload = asyncio.run(
|
||||||
|
_api_with_diagnostics(
|
||||||
|
_source("demo.personal"),
|
||||||
|
diagnostics=_ExtendedDiagnostics(),
|
||||||
|
).diagnose_source(source_id="demo.personal")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["status"] == "degraded"
|
||||||
|
assert dict(payload)["provider_latency_ms"] == 12
|
||||||
|
|
||||||
|
|
||||||
def test_diagnose_source_without_provider_returns_basic_status() -> None:
|
def test_diagnose_source_without_provider_returns_basic_status() -> None:
|
||||||
payload = asyncio.run(
|
payload = asyncio.run(
|
||||||
_api_with_diagnostics(_source("demo.personal")).diagnose_source(
|
_api_with_diagnostics(_source("demo.personal")).diagnose_source(
|
||||||
|
|||||||
@@ -163,6 +163,36 @@ async def test_rpc_health_and_capability_methods(tmp_path) -> None:
|
|||||||
assert called["result"]["output"] == {"value": "hello direct rpc"}
|
assert called["result"]["output"] == {"value": "hello direct rpc"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_source_discovery_preserves_inventory_contracts(tmp_path) -> 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:
|
||||||
|
listed = await _rpc(client, "workflow.sources.list", {"limit": 10})
|
||||||
|
inspected = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.sources.inspect",
|
||||||
|
{"source_id": "wf.std"},
|
||||||
|
)
|
||||||
|
diagnosed = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.sources.diagnose",
|
||||||
|
{"source_id": "wf.std"},
|
||||||
|
)
|
||||||
|
|
||||||
|
source = next(row for row in listed["result"]["sources"] if row["id"] == "wf.std")
|
||||||
|
assert source["kind"] == "system"
|
||||||
|
assert source["preview"]["node_specs"]
|
||||||
|
inventory = inspected["result"]["capabilities"]
|
||||||
|
assert inventory["node_spec_details"][0]["input_schema"]["type"] == "object"
|
||||||
|
assert inventory["reducer_details"][0]["ref"] == {
|
||||||
|
"source": "wf.std",
|
||||||
|
"capability_key": "add",
|
||||||
|
}
|
||||||
|
assert diagnosed["result"]["source_id"] == "wf.std"
|
||||||
|
assert diagnosed["result"]["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def test_rpc_capability_methods_preserve_saved_wrapper_fields(tmp_path) -> None:
|
async def test_rpc_capability_methods_preserve_saved_wrapper_fields(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
await server.api.create_artifact_from_plan(
|
await server.api.create_artifact_from_plan(
|
||||||
|
|||||||
@@ -117,6 +117,58 @@ def test_openrpc_exposes_typed_capability_call_result(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrpc_exposes_typed_source_list_result(
|
||||||
|
openrpc_document: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
_assert_result_component(
|
||||||
|
openrpc_document,
|
||||||
|
method_name="workflow.sources.list",
|
||||||
|
component_name="ListSourcesResult",
|
||||||
|
properties={"sources", "next_cursor", "total"},
|
||||||
|
)
|
||||||
|
sources = openrpc_document["components"]["schemas"]["ListSourcesResult"][
|
||||||
|
"properties"
|
||||||
|
]["sources"]
|
||||||
|
assert sources["items"] == {"$ref": "#/components/schemas/SourceStatusPayload"}
|
||||||
|
assert set(
|
||||||
|
openrpc_document["components"]["schemas"]["ListSourcesResult"]["required"]
|
||||||
|
) == {"sources", "next_cursor", "total"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrpc_exposes_typed_source_inspect_result(
|
||||||
|
openrpc_document: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
_assert_result_component(
|
||||||
|
openrpc_document,
|
||||||
|
method_name="workflow.sources.inspect",
|
||||||
|
component_name="InspectSourceResult",
|
||||||
|
properties={"id", "kind", "capabilities"},
|
||||||
|
)
|
||||||
|
inspected = openrpc_document["components"]["schemas"]["InspectSourceResult"]
|
||||||
|
assert inspected["properties"]["capabilities"] == {
|
||||||
|
"$ref": "#/components/schemas/SourceCapabilityInventoryPayload"
|
||||||
|
}
|
||||||
|
assert inspected["properties"]["diagnostics"]["anyOf"][:2] == [
|
||||||
|
{"$ref": "#/components/schemas/SourceDiagnosisResult"},
|
||||||
|
{"$ref": "#/components/schemas/SourceDiagnosticsUnavailablePayload"},
|
||||||
|
]
|
||||||
|
assert {"id", "kind", "capabilities"} <= set(inspected["required"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrpc_exposes_typed_source_diagnosis_result(
|
||||||
|
openrpc_document: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
_assert_result_component(
|
||||||
|
openrpc_document,
|
||||||
|
method_name="workflow.sources.diagnose",
|
||||||
|
component_name="SourceDiagnosisResult",
|
||||||
|
properties={"source_id", "status", "diagnostics"},
|
||||||
|
)
|
||||||
|
diagnosed = openrpc_document["components"]["schemas"]["SourceDiagnosisResult"]
|
||||||
|
assert set(diagnosed["required"]) == {"source_id", "status", "diagnostics"}
|
||||||
|
assert diagnosed["additionalProperties"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("method_name", "component_name", "properties"),
|
("method_name", "component_name", "properties"),
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user