feat: add validated workflow client boundary
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""Transport-independent workflow client primitives."""
|
||||
|
||||
from .codec import (
|
||||
DecodedRunResult,
|
||||
DecodedTracePage,
|
||||
decode_dependency_diagnostics,
|
||||
decode_deployment,
|
||||
decode_run_result,
|
||||
decode_trace_result,
|
||||
decode_workflow_artifact,
|
||||
)
|
||||
from .errors import (
|
||||
ArtifactNotFound,
|
||||
ArtifactVersionConflict,
|
||||
CapabilityNotFound,
|
||||
DeploymentNotRunnable,
|
||||
DeploymentRequired,
|
||||
InvalidResponse,
|
||||
ProtocolError,
|
||||
RevisionConflict,
|
||||
TransportError,
|
||||
ValidationFailed,
|
||||
WorkflowClientError,
|
||||
)
|
||||
from .protocols import WorkflowClientPort
|
||||
|
||||
__all__ = [
|
||||
"ArtifactNotFound",
|
||||
"ArtifactVersionConflict",
|
||||
"CapabilityNotFound",
|
||||
"DecodedRunResult",
|
||||
"DecodedTracePage",
|
||||
"DeploymentNotRunnable",
|
||||
"DeploymentRequired",
|
||||
"InvalidResponse",
|
||||
"ProtocolError",
|
||||
"RevisionConflict",
|
||||
"TransportError",
|
||||
"ValidationFailed",
|
||||
"WorkflowClientError",
|
||||
"WorkflowClientPort",
|
||||
"decode_dependency_diagnostics",
|
||||
"decode_deployment",
|
||||
"decode_run_result",
|
||||
"decode_trace_result",
|
||||
"decode_workflow_artifact",
|
||||
]
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Validated conversions from workflow API wire payloads to domain values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import fields as dataclass_fields
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from wf_api.models import (
|
||||
DependencyDiagnosticPayload,
|
||||
RawWorkflowPlan,
|
||||
RunResult,
|
||||
RunTraceResult,
|
||||
WorkflowArtifactPayload,
|
||||
WorkflowDeploymentPayload,
|
||||
)
|
||||
from wf_artifacts.models import (
|
||||
DependencyDiagnostic,
|
||||
WorkflowArtifact,
|
||||
WorkflowDeployment,
|
||||
)
|
||||
from wf_core.models.workflow import Workflow
|
||||
|
||||
from .errors import InvalidResponse
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DecodedRunFields:
|
||||
"""Domain-shaped run fields shared by normal and trace responses."""
|
||||
|
||||
artifact_id: str
|
||||
artifact_version: int
|
||||
deployment_id: str
|
||||
status: str
|
||||
run_id: str | None
|
||||
resume_readiness: str | None
|
||||
interrupt: dict[str, Any] | None
|
||||
outcome: str | None
|
||||
error: str | None
|
||||
output: dict[str, Any] | None
|
||||
trace_count: int
|
||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
||||
next_actions: dict[str, Any]
|
||||
trace: tuple[dict[str, Any], ...] | None = None
|
||||
trace_start: int | None = None
|
||||
trace_limit: int | None = None
|
||||
trace_truncated: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedRunResult(_DecodedRunFields):
|
||||
"""Validated run result without exposing a wire ``TypedDict``."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedTracePage(_DecodedRunFields):
|
||||
"""Validated bounded trace result without exposing wire dictionaries."""
|
||||
|
||||
|
||||
_PayloadT = TypeVar("_PayloadT")
|
||||
|
||||
|
||||
def _validate(payload: object, schema: type[_PayloadT], operation: str) -> _PayloadT:
|
||||
try:
|
||||
return TypeAdapter(schema).validate_python(payload)
|
||||
except (TypeError, ValueError, ValidationError) as exc:
|
||||
raise InvalidResponse(operation=operation, details=str(exc)) from exc
|
||||
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
|
||||
def _model_validate(model: type[_ModelT], payload: object, operation: str) -> _ModelT:
|
||||
try:
|
||||
# Pydantic models are the canonical domain validation boundary. This
|
||||
# helper keeps all malformed-response errors tied to their operation.
|
||||
return model.model_validate(payload)
|
||||
except (TypeError, ValueError, ValidationError) as exc:
|
||||
raise InvalidResponse(operation=operation, details=str(exc)) from exc
|
||||
|
||||
|
||||
def decode_workflow_artifact(
|
||||
payload: object,
|
||||
) -> tuple[WorkflowArtifact, Workflow]:
|
||||
"""Validate an artifact envelope and reconstruct its executable workflow."""
|
||||
operation = "workflow.artifacts.inspect"
|
||||
wire = _validate(payload, WorkflowArtifactPayload, operation)
|
||||
artifact = _model_validate(WorkflowArtifact, wire, operation)
|
||||
raw_plan = _model_validate(RawWorkflowPlan, wire["plan"], operation)
|
||||
workflow = _model_validate(
|
||||
Workflow,
|
||||
raw_plan.model_dump(mode="python", by_alias=True),
|
||||
operation,
|
||||
)
|
||||
return artifact, workflow
|
||||
|
||||
|
||||
def decode_deployment(payload: object) -> WorkflowDeployment:
|
||||
"""Validate and reconstruct one immutable deployment model."""
|
||||
operation = "workflow.deployments.inspect"
|
||||
wire = _validate(payload, WorkflowDeploymentPayload, operation)
|
||||
return _model_validate(WorkflowDeployment, wire, operation)
|
||||
|
||||
|
||||
def decode_dependency_diagnostics(
|
||||
payload: object,
|
||||
) -> tuple[DependencyDiagnostic, ...]:
|
||||
"""Decode deployment diagnostics into domain models."""
|
||||
return _decode_dependency_diagnostics(payload, "workflow.deployments.validate")
|
||||
|
||||
|
||||
def _decode_dependency_diagnostics(
|
||||
payload: object,
|
||||
operation: str,
|
||||
) -> tuple[DependencyDiagnostic, ...]:
|
||||
wire = _validate(payload, list[DependencyDiagnosticPayload], operation)
|
||||
return tuple(
|
||||
_model_validate(DependencyDiagnostic, diagnostic, operation)
|
||||
for diagnostic in wire
|
||||
)
|
||||
|
||||
|
||||
def _decode_run_fields(
|
||||
wire: RunResult | RunTraceResult,
|
||||
*,
|
||||
trace_required: bool,
|
||||
operation: str,
|
||||
) -> _DecodedRunFields:
|
||||
diagnostics = _decode_dependency_diagnostics(wire["diagnostics"], operation)
|
||||
raw_trace = wire.get("trace")
|
||||
trace = None
|
||||
if raw_trace is not None:
|
||||
# Copy validated TypedDict values into ordinary dictionaries at the
|
||||
# boundary so callers never receive transport DTO instances/types.
|
||||
trace = tuple(dict(entry) for entry in raw_trace)
|
||||
if trace_required and trace is None:
|
||||
# RunTraceResult validation makes this unreachable; retain a defensive
|
||||
# guard should its contract evolve.
|
||||
raise InvalidResponse(
|
||||
operation=operation,
|
||||
details="trace result did not include a trace page",
|
||||
)
|
||||
return _DecodedRunFields(
|
||||
artifact_id=wire["artifact_id"],
|
||||
artifact_version=wire["artifact_version"],
|
||||
deployment_id=wire["deployment_id"],
|
||||
status=wire["status"],
|
||||
run_id=wire["run_id"],
|
||||
resume_readiness=wire["resume_readiness"],
|
||||
interrupt=dict(wire["interrupt"]) if wire["interrupt"] is not None else None,
|
||||
outcome=wire["outcome"],
|
||||
error=wire["error"],
|
||||
output=dict(wire["output"]) if wire["output"] is not None else None,
|
||||
trace_count=wire["trace_count"],
|
||||
diagnostics=diagnostics,
|
||||
next_actions=dict(wire["next_actions"]),
|
||||
trace=trace,
|
||||
trace_start=wire.get("trace_start"),
|
||||
trace_limit=wire.get("trace_limit"),
|
||||
trace_truncated=wire.get("trace_truncated"),
|
||||
)
|
||||
|
||||
|
||||
def decode_run_result(payload: object) -> DecodedRunResult:
|
||||
"""Validate and decode a start/inspect/resume run response."""
|
||||
operation = "workflow.runs.inspect"
|
||||
wire = _validate(payload, RunResult, operation)
|
||||
fields = _decode_run_fields(
|
||||
wire,
|
||||
trace_required=False,
|
||||
operation=operation,
|
||||
)
|
||||
return DecodedRunResult(
|
||||
*(getattr(fields, field.name) for field in dataclass_fields(_DecodedRunFields))
|
||||
)
|
||||
|
||||
|
||||
def decode_trace_result(payload: object) -> DecodedTracePage:
|
||||
"""Validate and decode a bounded run trace response."""
|
||||
operation = "workflow.runs.trace"
|
||||
wire = _validate(payload, RunTraceResult, operation)
|
||||
fields = _decode_run_fields(
|
||||
wire,
|
||||
trace_required=True,
|
||||
operation=operation,
|
||||
)
|
||||
return DecodedTracePage(
|
||||
*(getattr(fields, field.name) for field in dataclass_fields(_DecodedRunFields))
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Stable exceptions raised by the transport-independent workflow client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class WorkflowClientError(Exception):
|
||||
"""Base class for errors that can be handled by workflow callers."""
|
||||
|
||||
|
||||
class TransportError(WorkflowClientError):
|
||||
"""The client could not communicate with the workflow service."""
|
||||
|
||||
|
||||
class ProtocolError(WorkflowClientError):
|
||||
"""The service returned a response that violates its protocol contract."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InvalidResponse(ProtocolError):
|
||||
"""A response failed validation for one named workflow operation."""
|
||||
|
||||
operation: str
|
||||
details: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "args", (str(self),))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"invalid response from {self.operation}: {self.details}"
|
||||
|
||||
|
||||
class CapabilityNotFound(WorkflowClientError):
|
||||
"""The requested remote capability does not exist."""
|
||||
|
||||
|
||||
class ArtifactNotFound(WorkflowClientError):
|
||||
"""The requested immutable artifact version does not exist."""
|
||||
|
||||
|
||||
class ArtifactVersionConflict(WorkflowClientError):
|
||||
"""An artifact version conflicts with an existing saved version."""
|
||||
|
||||
|
||||
class DeploymentRequired(WorkflowClientError):
|
||||
"""An operation requires a deployment selection."""
|
||||
|
||||
|
||||
class DeploymentNotRunnable(WorkflowClientError):
|
||||
"""A selected deployment failed its readiness checks."""
|
||||
|
||||
|
||||
class ValidationFailed(WorkflowClientError):
|
||||
"""A workflow or dependency validation operation reported errors."""
|
||||
|
||||
|
||||
class RevisionConflict(WorkflowClientError):
|
||||
"""An editable workflow revision is stale."""
|
||||
@@ -0,0 +1,152 @@
|
||||
"""The narrow transport port consumed by rich workflow client objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from wf_api.models import (
|
||||
CapabilityCallResult,
|
||||
InspectCapabilityResult,
|
||||
ListArtifactsResult,
|
||||
ListCapabilitiesResult,
|
||||
ListDeploymentsResult,
|
||||
ListRunsResult,
|
||||
RunResult,
|
||||
RunTraceResult,
|
||||
SaveArtifactResult,
|
||||
SaveDeploymentResult,
|
||||
ValidateArtifactPlanResult,
|
||||
ValidateDeploymentResult,
|
||||
WorkflowArtifactPayload,
|
||||
WorkflowDeploymentPayload,
|
||||
)
|
||||
from wf_api.runs import TraceRangeLike
|
||||
|
||||
|
||||
class WorkflowClientPort(Protocol):
|
||||
"""Minimum operation port required by transport-independent rich objects.
|
||||
|
||||
This deliberately does not inherit ``WorkflowApiSurface``: that protocol
|
||||
includes draft, source-admin, registry, and other operations that rich
|
||||
workflow objects do not need.
|
||||
"""
|
||||
|
||||
async def list_capabilities(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
source_id: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> ListCapabilitiesResult: ...
|
||||
|
||||
async def inspect_capability(
|
||||
self,
|
||||
*,
|
||||
qualified_name: str,
|
||||
) -> InspectCapabilityResult: ...
|
||||
|
||||
async def call_capability(
|
||||
self,
|
||||
*,
|
||||
qualified_name: str,
|
||||
payload: dict[str, Any],
|
||||
deployment_id: str | None = None,
|
||||
) -> CapabilityCallResult: ...
|
||||
|
||||
async def list_artifacts(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
kind: Literal["workflow", "wrapper"] | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> ListArtifactsResult: ...
|
||||
|
||||
async def inspect_artifact(
|
||||
self,
|
||||
*,
|
||||
artifact_id: str,
|
||||
version: int,
|
||||
) -> WorkflowArtifactPayload: ...
|
||||
|
||||
async def save_artifact(self, artifact: dict[str, Any]) -> SaveArtifactResult: ...
|
||||
|
||||
async def create_artifact_from_plan(
|
||||
self,
|
||||
*,
|
||||
artifact_id: str,
|
||||
version: int,
|
||||
title: str,
|
||||
plan: dict[str, Any],
|
||||
outcomes: Sequence[str],
|
||||
kind: Literal["workflow", "wrapper"] = "workflow",
|
||||
description: str | None = None,
|
||||
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
source_bindings: dict[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> SaveArtifactResult: ...
|
||||
|
||||
async def validate_artifact_plan(
|
||||
self,
|
||||
*,
|
||||
plan: dict[str, Any],
|
||||
outcomes: Sequence[str],
|
||||
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
source_bindings: dict[str, str] | None = None,
|
||||
) -> ValidateArtifactPlanResult: ...
|
||||
|
||||
async def list_deployments(self) -> ListDeploymentsResult: ...
|
||||
|
||||
async def inspect_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
) -> WorkflowDeploymentPayload: ...
|
||||
|
||||
async def save_deployment(
|
||||
self,
|
||||
deployment: dict[str, Any],
|
||||
) -> SaveDeploymentResult: ...
|
||||
|
||||
async def validate_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
live_check: bool = False,
|
||||
) -> ValidateDeploymentResult: ...
|
||||
|
||||
async def list_runs(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> ListRunsResult: ...
|
||||
|
||||
async def run_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
workflow_input: dict[str, Any],
|
||||
trace_range: TraceRangeLike | None = None,
|
||||
) -> RunResult: ...
|
||||
|
||||
async def inspect_run(self, *, run_id: str) -> RunResult: ...
|
||||
|
||||
async def resume_run(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str = "submitted",
|
||||
trace_range: TraceRangeLike | None = None,
|
||||
) -> RunResult: ...
|
||||
|
||||
async def read_run_trace(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
trace_range: TraceRangeLike,
|
||||
) -> RunTraceResult: ...
|
||||
Reference in New Issue
Block a user