"""Rich, transport-independent objects for remote workflow capabilities.""" from __future__ import annotations from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass, field from typing import Any from jsonschema import Draft202012Validator, SchemaError, ValidationError from wf_artifacts.models import DependencyDiagnostic from wf_core.models.schemas import NodeDef, SchemaRef from wf_platform import CapabilityRef from ._identity import require_response_identity from ._repr import html_repr, short_repr from .codec import decode_capability_call, decode_capability_diagnostics from .errors import InvalidResponse from .protocols import WorkflowClientPort @dataclass(frozen=True, slots=True) class CapabilitySummary: """Compact immutable discovery row for a planner-visible capability.""" qualified_name: str source_id: str kind: str description: str | None outcomes: tuple[str, ...] is_async: bool input_fields: tuple[str, ...] output_fields: tuple[str, ...] artifact_id: str | None = None version: int | None = None title: str | None = None @property def name(self) -> str: """Compatibility alias for the wire row's ``name`` field.""" return self.qualified_name def __repr__(self) -> str: return short_repr( type(self).__name__, name=self.qualified_name, source=self.source_id, outcomes=self.outcomes, ) def _repr_html_(self) -> str: return html_repr( type(self).__name__, name=self.qualified_name, source=self.source_id, outcomes=self.outcomes, inputs=f"{len(self.input_fields)} fields", outputs=f"{len(self.output_fields)} fields", ) @dataclass(frozen=True, slots=True) class CapabilityResult: """Validated result of invoking one remote capability.""" outcome: str output: dict[str, Any] | None diagnostics: tuple[DependencyDiagnostic, ...] def __repr__(self) -> str: return short_repr( type(self).__name__, outcome=self.outcome, output=self.output, diagnostics=f"{len(self.diagnostics)} diagnostics", ) def _repr_html_(self) -> str: return html_repr( type(self).__name__, outcome=self.outcome, output=self.output, diagnostics=f"{len(self.diagnostics)} diagnostics", ) def _check_schema(schema: object, *, operation: str) -> dict[str, Any]: if not isinstance(schema, Mapping): raise InvalidResponse( operation=operation, details="capability schema must be a JSON object", ) schema_copy = deepcopy(dict(schema)) try: Draft202012Validator.check_schema(schema_copy) except SchemaError as exc: raise InvalidResponse( operation=operation, details=f"invalid JSON Schema: {exc.message}", ) from exc return schema_copy @dataclass(frozen=True, slots=True) class RemoteCapability: """Inspected remote capability that validates calls against its contract.""" _port: WorkflowClientPort = field(repr=False, compare=False) ref: CapabilityRef qualified_name: str description: str | None input_schema: dict[str, Any] output_schema: dict[str, Any] outcomes: tuple[str, ...] is_async: bool _kind: str = field(default="node_spec", repr=False, compare=False) def __post_init__(self) -> None: # Freeze the public container shape at construction. The nested JSON # values remain ordinary JSON objects because callers expect to inspect # and pass schemas directly to existing pydantic/core APIs. if not self.outcomes: raise InvalidResponse( operation="workflow.capabilities.inspect", details="capability contract must declare at least one outcome", ) object.__setattr__( self, "input_schema", _check_schema( self.input_schema, operation="workflow.capabilities.inspect", ), ) object.__setattr__( self, "output_schema", _check_schema( self.output_schema, operation="workflow.capabilities.inspect", ), ) object.__setattr__(self, "outcomes", tuple(self.outcomes)) def __repr__(self) -> str: return short_repr( type(self).__name__, name=self.qualified_name, outcomes=self.outcomes, input_schema=f"{len(self.input_schema)} keys", output_schema=f"{len(self.output_schema)} keys", ) def _repr_html_(self) -> str: return html_repr( type(self).__name__, name=self.qualified_name, description=self.description, outcomes=self.outcomes, **{ "input schema": f"{len(self.input_schema)} keys", "output schema": f"{len(self.output_schema)} keys", }, ) async def __call__( self, payload: Mapping[str, Any] | None = None, /, **fields: Any, ) -> CapabilityResult: if payload is not None and fields: raise TypeError("pass a payload mapping or keyword fields, not both") return await self.call(dict(payload) if payload is not None else fields) async def call( self, payload: Mapping[str, Any], *, deployment_id: str | None = None, ) -> CapabilityResult: """Validate input locally, invoke remotely, and validate its result.""" input_payload = dict(payload) input_validator = Draft202012Validator(self.input_schema) # jsonschema.ValidationError intentionally remains the local input # error: no transport operation has happened when it is raised. input_validator.validate(input_payload) wire = decode_capability_call( await self._port.call_capability( qualified_name=self.qualified_name, payload=input_payload, deployment_id=deployment_id, ) ) if wire["qualified_name"] != self.qualified_name: raise InvalidResponse( operation="workflow.capabilities.call", details=( f"result qualified name {wire['qualified_name']!r} does not " f"match requested {self.qualified_name!r}" ), ) require_response_identity( operation="workflow.capabilities.call", actual={ "source_id": wire["source_id"], "kind": wire["kind"], "deployment_id": wire["deployment_id"], }, expected={ "source_id": str(self.ref.source), "kind": self._kind, # Direct node calls intentionally ignore deployment ids; saved # wrapper capabilities echo the selected deployment exactly. "deployment_id": ( deployment_id if self._kind == "wrapper_artifact" else None ), }, ) if wire["outcome"] not in self.outcomes: raise InvalidResponse( operation="workflow.capabilities.call", details=f"unknown capability outcome {wire['outcome']!r}", ) output = wire["output"] if output is not None: try: Draft202012Validator(self.output_schema).validate(output) except ValidationError as exc: # Schema validation errors are expected server-contract # failures; do not leak jsonschema internals as public output. raise InvalidResponse( operation="workflow.capabilities.call", details=f"output does not match capability schema: {exc}", ) from exc return CapabilityResult( outcome=wire["outcome"], output=deepcopy(output) if output is not None else None, diagnostics=decode_capability_diagnostics(wire["diagnostics"]), ) def node_def(self) -> NodeDef: """Return the schema contract consumed by ``WorkflowBuilder.use_contract``.""" return NodeDef( name=self.qualified_name, input_schema=SchemaRef.model_validate(deepcopy(self.input_schema)), output_schema=SchemaRef.model_validate(deepcopy(self.output_schema)), outcomes=list(self.outcomes), )