live check opt-in
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from mcp.client.streamable_http import StreamableHTTPError
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
from wf_artifacts import (
|
||||
ArtifactKind,
|
||||
AvailableCapability,
|
||||
@@ -41,6 +47,7 @@ from wf_core.models.steps import (
|
||||
)
|
||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||
|
||||
from ..broker.service.adapters import require_adapter
|
||||
from ..events import make_event
|
||||
from ..models import RawWorkflowPlan
|
||||
from ..shared import matches_query, paged_list_payload
|
||||
@@ -74,6 +81,19 @@ from .wrapper_hints import (
|
||||
wrapper_hints_for_capability,
|
||||
)
|
||||
|
||||
LIVE_SOURCE_CHECK_TIMEOUT_SECONDS = 8.0
|
||||
_LIVE_SOURCE_CHECK_FAILURES = (
|
||||
KeyError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
anyio.ClosedResourceError,
|
||||
anyio.EndOfStream,
|
||||
anyio.BrokenResourceError,
|
||||
httpx.HTTPError,
|
||||
McpError,
|
||||
StreamableHTTPError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wf_core import RunState
|
||||
|
||||
@@ -933,10 +953,23 @@ class WorkflowSurfaceHandlers:
|
||||
"saved": True,
|
||||
}
|
||||
|
||||
async def validate_deployment(self, *, deployment_id: str) -> dict[str, Any]:
|
||||
deployment, artifact, diagnostics, _tree = self._deployment_validation(
|
||||
async def validate_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
live_check: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
deployment, artifact, diagnostics, tree = self._deployment_validation(
|
||||
deployment_id
|
||||
)
|
||||
if live_check:
|
||||
diagnostics.extend(
|
||||
await _live_source_diagnostics(
|
||||
self.service,
|
||||
deployment=deployment,
|
||||
artifacts=[artifact, *tree.artifacts_by_ref.values()],
|
||||
)
|
||||
)
|
||||
return {
|
||||
"deployment_id": deployment.id,
|
||||
"artifact_id": artifact.id,
|
||||
@@ -1214,6 +1247,70 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
|
||||
return sources
|
||||
|
||||
|
||||
async def _live_source_diagnostics(
|
||||
service: WfMcpService,
|
||||
*,
|
||||
deployment: WorkflowDeployment,
|
||||
artifacts: Sequence[WorkflowArtifact],
|
||||
) -> list[DependencyDiagnostic]:
|
||||
"""Return opt-in diagnostics for bound upstream sources that cannot answer.
|
||||
|
||||
Static deployment validation only checks the last known source catalog.
|
||||
This probe intentionally performs live upstream I/O, so MCP tools keep it
|
||||
disabled by default and only run it when the caller asks for liveness.
|
||||
"""
|
||||
diagnostics: list[DependencyDiagnostic] = []
|
||||
for source_id, logical_ref in _required_live_sources(deployment, artifacts).items():
|
||||
source = service.capability_sources.get(source_id)
|
||||
if (
|
||||
source is None
|
||||
or not source.enabled
|
||||
or not source.permissions.calls_upstream
|
||||
):
|
||||
continue
|
||||
try:
|
||||
connection = service.connections.get(source_id)
|
||||
adapter = require_adapter(connection, service.adapters)
|
||||
auth = service.load_auth(source_id)
|
||||
await asyncio.wait_for(
|
||||
adapter.list_tools(connection, auth),
|
||||
timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except _LIVE_SOURCE_CHECK_FAILURES as exc:
|
||||
diagnostics.append(
|
||||
DependencyDiagnostic(
|
||||
severity=DiagnosticSeverity.ERROR,
|
||||
code="source_unreachable",
|
||||
logical_ref=logical_ref,
|
||||
bound_source=source_id,
|
||||
message=(
|
||||
f"Live check for upstream source {source_id!r} failed: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
),
|
||||
repair_hint=(
|
||||
"Start or reconnect the source, fix its transport/auth "
|
||||
"configuration, or bind this deployment to another source."
|
||||
),
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _required_live_sources(
|
||||
deployment: WorkflowDeployment,
|
||||
artifacts: Sequence[WorkflowArtifact],
|
||||
) -> dict[str, str]:
|
||||
"""Return concrete upstream source ids to live-check, with one logical ref."""
|
||||
bindings = deployment.binding_map()
|
||||
required: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
for logical_ref, capability in artifact.required_capability_map().items():
|
||||
source_id = bindings.get(capability.logical_source)
|
||||
if source_id is not None:
|
||||
required.setdefault(source_id, logical_ref)
|
||||
return required
|
||||
|
||||
|
||||
def _required_capabilities_for_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
|
||||
@@ -596,8 +596,26 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
title="Validate Workflow Deployment",
|
||||
description="Check whether a deployment_id can run with currently enabled sources.",
|
||||
)
|
||||
async def validate_deployment(deployment_id: str) -> dict[str, Any]:
|
||||
return await handlers.validate_deployment(deployment_id=deployment_id)
|
||||
async def validate_deployment(
|
||||
deployment_id: Annotated[
|
||||
str,
|
||||
Field(description="Saved workflow deployment id to validate."),
|
||||
],
|
||||
live_check: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description=(
|
||||
"When true, also contact each required upstream source to "
|
||||
"verify it is reachable. Defaults false because this may "
|
||||
"spawn stdio servers or perform network I/O."
|
||||
)
|
||||
),
|
||||
] = False,
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.validate_deployment(
|
||||
deployment_id=deployment_id,
|
||||
live_check=live_check,
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.run_deployment",
|
||||
|
||||
Reference in New Issue
Block a user