fix: harden Python workflow client boundary
This commit is contained in:
@@ -62,9 +62,7 @@ class WorkflowDeploymentApi:
|
||||
.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
async def save_deployment(
|
||||
self, deployment: dict[str, Any]
|
||||
) -> SaveDeploymentResult:
|
||||
async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
|
||||
workflow_deployment = WorkflowDeployment.model_validate(deployment)
|
||||
self._artifact_store().save_deployment(workflow_deployment)
|
||||
self.context.events.record_workflow_event(
|
||||
@@ -83,9 +81,7 @@ class WorkflowDeploymentApi:
|
||||
"saved": True,
|
||||
}
|
||||
|
||||
async def delete_deployment(
|
||||
self, *, deployment_id: str
|
||||
) -> DeleteDeploymentResult:
|
||||
async def delete_deployment(self, *, deployment_id: str) -> DeleteDeploymentResult:
|
||||
"""Delete one mutable deployment environment binding."""
|
||||
self._artifact_store().delete_deployment(deployment_id)
|
||||
self.context.events.record_workflow_event(
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_core import ReducerRef, Workflow
|
||||
from wf_core.models.workflow_refs import WorkflowRef
|
||||
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
|
||||
@@ -69,7 +71,7 @@ def _required_object_field(plan: JsonObject, field_name: str) -> JsonObject:
|
||||
def _validate_workflow_plan(plan: JsonObject) -> None:
|
||||
try:
|
||||
workflow = Workflow.model_validate(plan)
|
||||
except Exception as exc:
|
||||
except ValidationError as exc:
|
||||
raise WorkflowPlanValidationError(f"invalid workflow plan: {exc}") from exc
|
||||
|
||||
node_ids = {node.id for node in workflow.nodes}
|
||||
|
||||
@@ -5,22 +5,6 @@ from wf_platform import CapabilityRef, Page
|
||||
from .app import App
|
||||
from .authoring import EditableWorkflow
|
||||
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
|
||||
from .codec import (
|
||||
DecodedRunResult,
|
||||
DecodedTracePage,
|
||||
decode_capabilities_page,
|
||||
decode_capability_call,
|
||||
decode_capability_diagnostics,
|
||||
decode_capability_inspect,
|
||||
decode_dependency_diagnostics,
|
||||
decode_deployment,
|
||||
decode_deployment_validation,
|
||||
decode_deployments,
|
||||
decode_run_result,
|
||||
decode_trace_result,
|
||||
decode_validate_artifact_plan,
|
||||
decode_workflow_artifact,
|
||||
)
|
||||
from .deployments import Deployment, DeploymentValidation
|
||||
from .errors import (
|
||||
ArtifactNotFound,
|
||||
@@ -35,7 +19,6 @@ from .errors import (
|
||||
ValidationFailed,
|
||||
WorkflowClientError,
|
||||
)
|
||||
from .protocols import WorkflowClientPort
|
||||
from .runs import Run, TracePage
|
||||
from .workflows import (
|
||||
ArtifactRef,
|
||||
@@ -54,8 +37,6 @@ __all__ = [
|
||||
"CapabilityRef",
|
||||
"CapabilityResult",
|
||||
"CapabilitySummary",
|
||||
"DecodedRunResult",
|
||||
"DecodedTracePage",
|
||||
"Diagnostic",
|
||||
"DeploymentNotRunnable",
|
||||
"DeploymentRequired",
|
||||
@@ -70,22 +51,9 @@ __all__ = [
|
||||
"TransportError",
|
||||
"ValidationFailed",
|
||||
"WorkflowClientError",
|
||||
"WorkflowClientPort",
|
||||
"EditableWorkflow",
|
||||
"WorkflowArtifact",
|
||||
"WorkflowDiagnostic",
|
||||
"WorkflowValidation",
|
||||
"TracePage",
|
||||
"decode_capabilities_page",
|
||||
"decode_capability_call",
|
||||
"decode_capability_diagnostics",
|
||||
"decode_capability_inspect",
|
||||
"decode_dependency_diagnostics",
|
||||
"decode_deployment_validation",
|
||||
"decode_deployments",
|
||||
"decode_deployment",
|
||||
"decode_run_result",
|
||||
"decode_trace_result",
|
||||
"decode_validate_artifact_plan",
|
||||
"decode_workflow_artifact",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""HTTP client-port adapter that exposes only public ``wf_client`` errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from json import JSONDecodeError
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
import httpx
|
||||
|
||||
from wf_api.models import (
|
||||
CapabilityCallResult,
|
||||
InspectCapabilityResult,
|
||||
ListCapabilitiesResult,
|
||||
ListDeploymentsResult,
|
||||
RunResult,
|
||||
RunTraceResult,
|
||||
SaveArtifactResult,
|
||||
SaveDeploymentResult,
|
||||
ValidateArtifactPlanResult,
|
||||
ValidateDeploymentResult,
|
||||
WorkflowArtifactPayload,
|
||||
WorkflowDeploymentPayload,
|
||||
)
|
||||
from wf_api.runs import TraceRangeLike
|
||||
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||
from wf_transport_rpc_http.client.base import RpcProtocolError
|
||||
|
||||
from .errors import (
|
||||
ArtifactNotFound,
|
||||
ArtifactVersionConflict,
|
||||
CapabilityNotFound,
|
||||
DeploymentNotRunnable,
|
||||
DeploymentRequired,
|
||||
ProtocolError,
|
||||
RevisionConflict,
|
||||
TransportError,
|
||||
WorkflowClientError,
|
||||
)
|
||||
|
||||
_ResultT = TypeVar("_ResultT")
|
||||
|
||||
|
||||
def _server_detail(error: RpcProtocolError) -> tuple[str | None, str]:
|
||||
data = error.data
|
||||
if not isinstance(data, dict):
|
||||
return error.code if isinstance(error.code, str) else None, error.message
|
||||
code = data.get("code")
|
||||
detail = data.get("message")
|
||||
return (
|
||||
(
|
||||
code
|
||||
if isinstance(code, str)
|
||||
else error.code
|
||||
if isinstance(error.code, str)
|
||||
else None
|
||||
),
|
||||
detail if isinstance(detail, str) else error.message,
|
||||
)
|
||||
|
||||
|
||||
def _known_protocol_error(
|
||||
operation: str,
|
||||
error: RpcProtocolError,
|
||||
) -> WorkflowClientError | None:
|
||||
"""Translate only stable codes or exact legacy missing-resource signals."""
|
||||
code, detail = _server_detail(error)
|
||||
normalized = code.casefold() if code is not None else ""
|
||||
if normalized in {"capability_not_found", "capabilitynotfound"}:
|
||||
return CapabilityNotFound(detail)
|
||||
if normalized in {"artifact_not_found", "artifactnotfound"}:
|
||||
return ArtifactNotFound(detail)
|
||||
if normalized in {"artifact_version_conflict", "artifactversionconflict"}:
|
||||
return ArtifactVersionConflict(detail)
|
||||
if normalized in {"revision_conflict", "revisionconflict"}:
|
||||
return RevisionConflict(detail)
|
||||
if normalized in {"deployment_required", "deploymentrequired"}:
|
||||
return DeploymentRequired()
|
||||
if normalized in {"deployment_not_runnable", "deploymentnotrunnable"}:
|
||||
return DeploymentNotRunnable(error=detail)
|
||||
|
||||
# The current RPC server reports expected application exception class names
|
||||
# in ``data.code``. A generic KeyError is safe to specialize only when both
|
||||
# the operation and its exact resource phrase agree.
|
||||
if code == "KeyError":
|
||||
if operation.startswith("workflow.capabilities.") and (
|
||||
"unknown workflow capability" in detail
|
||||
):
|
||||
return CapabilityNotFound(detail)
|
||||
if operation == "workflow.artifacts.inspect" and (
|
||||
"unknown workflow artifact" in detail
|
||||
):
|
||||
return ArtifactNotFound(detail)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PublicErrorWorkflowClientPort:
|
||||
"""Delegate RPC operations while preventing transport exception leakage."""
|
||||
|
||||
_rpc: RpcWorkflowApiClient
|
||||
|
||||
async def _invoke(
|
||||
self,
|
||||
operation: str,
|
||||
call: Callable[..., Awaitable[_ResultT]],
|
||||
/,
|
||||
**params: Any,
|
||||
) -> _ResultT:
|
||||
try:
|
||||
return await call(**params)
|
||||
except RpcProtocolError as exc:
|
||||
known = _known_protocol_error(operation, exc)
|
||||
if known is not None:
|
||||
raise known from exc
|
||||
raise ProtocolError(exc.code, exc.message, exc.data) from exc
|
||||
except (httpx.TransportError, httpx.HTTPStatusError, JSONDecodeError) as exc:
|
||||
raise TransportError(f"{operation} transport failed: {exc}") from exc
|
||||
except RuntimeError as exc:
|
||||
# The RPC transport uses RuntimeError only when a decoded JSON-RPC
|
||||
# result is not an object. That is a protocol failure, not a public
|
||||
# transport implementation detail.
|
||||
raise ProtocolError(None, f"{operation}: {exc}") from exc
|
||||
|
||||
async def list_capabilities(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
source_id: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> ListCapabilitiesResult:
|
||||
return await self._invoke(
|
||||
"workflow.capabilities.list",
|
||||
self._rpc.list_capabilities,
|
||||
query=query,
|
||||
source_id=source_id,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def inspect_capability(
|
||||
self, *, qualified_name: str
|
||||
) -> InspectCapabilityResult:
|
||||
return await self._invoke(
|
||||
"workflow.capabilities.inspect",
|
||||
self._rpc.inspect_capability,
|
||||
qualified_name=qualified_name,
|
||||
)
|
||||
|
||||
async def call_capability(
|
||||
self,
|
||||
*,
|
||||
qualified_name: str,
|
||||
payload: dict[str, Any],
|
||||
deployment_id: str | None = None,
|
||||
) -> CapabilityCallResult:
|
||||
return await self._invoke(
|
||||
"workflow.capabilities.call",
|
||||
self._rpc.call_capability,
|
||||
qualified_name=qualified_name,
|
||||
payload=payload,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
async def inspect_artifact(
|
||||
self, *, artifact_id: str, version: int
|
||||
) -> WorkflowArtifactPayload:
|
||||
return await self._invoke(
|
||||
"workflow.artifacts.inspect",
|
||||
self._rpc.inspect_artifact,
|
||||
artifact_id=artifact_id,
|
||||
version=version,
|
||||
)
|
||||
|
||||
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:
|
||||
return await self._invoke(
|
||||
"workflow.artifacts.create_from_plan",
|
||||
self._rpc.create_artifact_from_plan,
|
||||
artifact_id=artifact_id,
|
||||
version=version,
|
||||
title=title,
|
||||
plan=plan,
|
||||
outcomes=outcomes,
|
||||
kind=kind,
|
||||
description=description,
|
||||
required_capabilities=required_capabilities,
|
||||
source_bindings=source_bindings,
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
|
||||
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:
|
||||
return await self._invoke(
|
||||
"workflow.artifacts.validate_plan",
|
||||
self._rpc.validate_artifact_plan,
|
||||
plan=plan,
|
||||
outcomes=outcomes,
|
||||
required_capabilities=required_capabilities,
|
||||
source_bindings=source_bindings,
|
||||
)
|
||||
|
||||
async def list_deployments(self) -> ListDeploymentsResult:
|
||||
return await self._invoke(
|
||||
"workflow.deployments.list",
|
||||
self._rpc.list_deployments,
|
||||
)
|
||||
|
||||
async def inspect_deployment(
|
||||
self, *, deployment_id: str
|
||||
) -> WorkflowDeploymentPayload:
|
||||
return await self._invoke(
|
||||
"workflow.deployments.inspect",
|
||||
self._rpc.inspect_deployment,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
|
||||
return await self._invoke(
|
||||
"workflow.deployments.save",
|
||||
self._rpc.save_deployment,
|
||||
deployment=deployment,
|
||||
)
|
||||
|
||||
async def validate_deployment(
|
||||
self, *, deployment_id: str, live_check: bool = False
|
||||
) -> ValidateDeploymentResult:
|
||||
return await self._invoke(
|
||||
"workflow.deployments.validate",
|
||||
self._rpc.validate_deployment,
|
||||
deployment_id=deployment_id,
|
||||
live_check=live_check,
|
||||
)
|
||||
|
||||
async def run_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
workflow_input: dict[str, Any],
|
||||
trace_range: TraceRangeLike | None = None,
|
||||
) -> RunResult:
|
||||
return await self._invoke(
|
||||
"workflow.runs.start",
|
||||
self._rpc.run_deployment,
|
||||
deployment_id=deployment_id,
|
||||
workflow_input=workflow_input,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
async def inspect_run(self, *, run_id: str) -> RunResult:
|
||||
return await self._invoke(
|
||||
"workflow.runs.inspect",
|
||||
self._rpc.inspect_run,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
async def resume_run(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str = "submitted",
|
||||
trace_range: TraceRangeLike | None = None,
|
||||
) -> RunResult:
|
||||
return await self._invoke(
|
||||
"workflow.runs.resume",
|
||||
self._rpc.resume_run,
|
||||
run_id=run_id,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
async def read_run_trace(
|
||||
self, *, run_id: str, trace_range: TraceRangeLike
|
||||
) -> RunTraceResult:
|
||||
return await self._invoke(
|
||||
"workflow.runs.trace",
|
||||
self._rpc.read_run_trace,
|
||||
run_id=run_id,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Strict identity checks for reconstructing public client snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .errors import InvalidResponse
|
||||
|
||||
|
||||
def require_response_identity(
|
||||
*,
|
||||
operation: str,
|
||||
actual: Mapping[str, object],
|
||||
expected: Mapping[str, object],
|
||||
) -> None:
|
||||
"""Reject a validly shaped response that belongs to another resource.
|
||||
|
||||
Shape validation alone cannot prevent a server, proxy, or cache from
|
||||
returning the wrong resource. Keeping this check centralized makes every
|
||||
public reconstruction boundary report the operation and mismatched field.
|
||||
"""
|
||||
for field, expected_value in expected.items():
|
||||
actual_value = actual.get(field)
|
||||
if actual_value != expected_value:
|
||||
raise InvalidResponse(
|
||||
operation=operation,
|
||||
details=(
|
||||
f"response {field} {actual_value!r} does not match "
|
||||
f"requested {expected_value!r}"
|
||||
),
|
||||
)
|
||||
+13
-3
@@ -9,6 +9,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from wf_platform import CapabilityRef, Page, SourceRef
|
||||
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||
|
||||
from ._http_port import PublicErrorWorkflowClientPort
|
||||
from ._identity import require_response_identity
|
||||
from .authoring import EditableWorkflow
|
||||
from .capabilities import CapabilitySummary, RemoteCapability
|
||||
from .codec import (
|
||||
@@ -82,9 +84,11 @@ class App:
|
||||
) -> App:
|
||||
"""Configure a lazy HTTP JSON-RPC connection without performing I/O."""
|
||||
return cls(
|
||||
_port=RpcWorkflowApiClient(
|
||||
url=url,
|
||||
timeout_seconds=timeout_seconds,
|
||||
_port=PublicErrorWorkflowClientPort(
|
||||
RpcWorkflowApiClient(
|
||||
url=url,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
),
|
||||
endpoint=url,
|
||||
)
|
||||
@@ -118,6 +122,7 @@ class App:
|
||||
output_schema=dict(wire["output_schema"]),
|
||||
outcomes=tuple(wire["outcomes"]),
|
||||
is_async=wire["is_async"],
|
||||
_kind=wire["kind"],
|
||||
)
|
||||
|
||||
async def capabilities(
|
||||
@@ -170,6 +175,11 @@ class App:
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
require_response_identity(
|
||||
operation="workflow.artifacts.inspect",
|
||||
actual={"artifact_id": artifact.id, "version": artifact.version},
|
||||
expected={"artifact_id": artifact_id, "version": version},
|
||||
)
|
||||
return WorkflowArtifact(self._port, artifact, workflow)
|
||||
|
||||
async def edit_workflow(
|
||||
|
||||
+33
-12
@@ -19,8 +19,13 @@ from wf_core import (
|
||||
Workflow,
|
||||
)
|
||||
|
||||
from ._identity import require_response_identity
|
||||
from .capabilities import RemoteCapability
|
||||
from .codec import decode_validate_artifact_plan, decode_workflow_artifact
|
||||
from .codec import (
|
||||
decode_save_artifact,
|
||||
decode_validate_artifact_plan,
|
||||
decode_workflow_artifact,
|
||||
)
|
||||
from .protocols import WorkflowClientPort
|
||||
from .workflows import (
|
||||
ArtifactRef,
|
||||
@@ -39,9 +44,7 @@ class EditableWorkflow(WorkflowBuilder):
|
||||
artifact_title: str | None = field(default=None, kw_only=True)
|
||||
artifact_description: str | None = field(default=None, kw_only=True)
|
||||
_source_plan: dict[str, Any] | None = field(default=None, repr=False, kw_only=True)
|
||||
_source_workflow: Workflow | None = field(
|
||||
default=None, repr=False, kw_only=True
|
||||
)
|
||||
_source_workflow: Workflow | None = field(default=None, repr=False, kw_only=True)
|
||||
_permissive_node_defs: set[str] = field(
|
||||
default_factory=set, repr=False, kw_only=True
|
||||
)
|
||||
@@ -191,18 +194,31 @@ class EditableWorkflow(WorkflowBuilder):
|
||||
validation = await self.validate()
|
||||
validation.raise_for_errors()
|
||||
_workflow, plan = self._plan()
|
||||
saved_id = artifact_id or (self.based_on.artifact_id if self.based_on else self.name)
|
||||
saved_id = artifact_id or (
|
||||
self.based_on.artifact_id if self.based_on else self.name
|
||||
)
|
||||
saved_title = title if title is not None else self.artifact_title or self.name
|
||||
saved_description = (
|
||||
description if description is not None else self.artifact_description
|
||||
)
|
||||
await self._port.create_artifact_from_plan(
|
||||
artifact_id=saved_id,
|
||||
version=version,
|
||||
title=saved_title,
|
||||
plan=plan,
|
||||
outcomes=tuple(self.outcomes),
|
||||
description=saved_description,
|
||||
acknowledgement = decode_save_artifact(
|
||||
await self._port.create_artifact_from_plan(
|
||||
artifact_id=saved_id,
|
||||
version=version,
|
||||
title=saved_title,
|
||||
plan=plan,
|
||||
outcomes=tuple(self.outcomes),
|
||||
description=saved_description,
|
||||
)
|
||||
)
|
||||
require_response_identity(
|
||||
operation="workflow.artifacts.create_from_plan",
|
||||
actual={
|
||||
"artifact_id": acknowledgement["artifact_id"],
|
||||
"version": acknowledgement["version"],
|
||||
"saved": acknowledgement["saved"],
|
||||
},
|
||||
expected={"artifact_id": saved_id, "version": version, "saved": True},
|
||||
)
|
||||
# The acknowledgement is only an identity signal. Inspecting the exact
|
||||
# requested version ensures server normalization is retained losslessly.
|
||||
@@ -211,6 +227,11 @@ class EditableWorkflow(WorkflowBuilder):
|
||||
version=version,
|
||||
)
|
||||
artifact, workflow = decode_workflow_artifact(inspected)
|
||||
require_response_identity(
|
||||
operation="workflow.artifacts.inspect",
|
||||
actual={"artifact_id": artifact.id, "version": artifact.version},
|
||||
expected={"artifact_id": saved_id, "version": version},
|
||||
)
|
||||
return WorkflowArtifact(self._port, artifact, workflow)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -113,6 +114,7 @@ class RemoteCapability:
|
||||
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
|
||||
@@ -200,6 +202,23 @@ class RemoteCapability:
|
||||
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",
|
||||
|
||||
@@ -17,6 +17,8 @@ from wf_api.models import (
|
||||
RawWorkflowPlan,
|
||||
RunResult,
|
||||
RunTraceResult,
|
||||
SaveArtifactResult,
|
||||
SaveDeploymentResult,
|
||||
ValidateArtifactPlanResult,
|
||||
ValidateDeploymentResult,
|
||||
WorkflowArtifactPayload,
|
||||
@@ -121,6 +123,24 @@ def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult
|
||||
)
|
||||
|
||||
|
||||
def decode_save_artifact(payload: object) -> SaveArtifactResult:
|
||||
"""Validate an artifact creation acknowledgement."""
|
||||
return _validate(
|
||||
payload,
|
||||
SaveArtifactResult,
|
||||
"workflow.artifacts.create_from_plan",
|
||||
)
|
||||
|
||||
|
||||
def decode_save_deployment(payload: object) -> SaveDeploymentResult:
|
||||
"""Validate a deployment save acknowledgement."""
|
||||
return _validate(
|
||||
payload,
|
||||
SaveDeploymentResult,
|
||||
"workflow.deployments.save",
|
||||
)
|
||||
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
|
||||
|
||||
@@ -51,22 +51,64 @@ class DeploymentValidation:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, init=False)
|
||||
class Deployment:
|
||||
"""Immutable snapshot of one configured artifact deployment."""
|
||||
|
||||
_port: WorkflowClientPort = field(repr=False, compare=False)
|
||||
model: WorkflowDeployment
|
||||
diagnostics: tuple[DependencyDiagnostic, ...] = ()
|
||||
_model: WorkflowDeployment = field(repr=False)
|
||||
_diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
|
||||
runnable: bool | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
_port: WorkflowClientPort,
|
||||
model: WorkflowDeployment,
|
||||
diagnostics: tuple[DependencyDiagnostic, ...] = (),
|
||||
runnable: bool | None = None,
|
||||
) -> None:
|
||||
# Pydantic models remain mutable even inside a frozen dataclass. Keep
|
||||
# private copies so public inspection cannot retarget later calls.
|
||||
object.__setattr__(self, "_port", _port)
|
||||
object.__setattr__(self, "_model", model.model_copy(deep=True))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_diagnostics",
|
||||
tuple(item.model_copy(deep=True) for item in diagnostics),
|
||||
)
|
||||
object.__setattr__(self, "runnable", runnable)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, port: WorkflowClientPort, payload: object) -> Deployment:
|
||||
return cls(_port=port, model=decode_deployment(payload))
|
||||
|
||||
def with_validation(
|
||||
self,
|
||||
*,
|
||||
diagnostics: tuple[DependencyDiagnostic, ...],
|
||||
runnable: bool,
|
||||
) -> Deployment:
|
||||
"""Return a new snapshot enriched with one validation result."""
|
||||
return type(self)(
|
||||
_port=self._port,
|
||||
model=self._model,
|
||||
diagnostics=diagnostics,
|
||||
runnable=runnable,
|
||||
)
|
||||
|
||||
@property
|
||||
def model(self) -> WorkflowDeployment:
|
||||
"""Return a defensive copy of the deployment domain model."""
|
||||
return self._model.model_copy(deep=True)
|
||||
|
||||
@property
|
||||
def diagnostics(self) -> tuple[DependencyDiagnostic, ...]:
|
||||
"""Return defensive copies of loaded dependency diagnostics."""
|
||||
return tuple(item.model_copy(deep=True) for item in self._diagnostics)
|
||||
|
||||
@property
|
||||
def deployment_id(self) -> str:
|
||||
return self.model.id
|
||||
return self._model.id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
@@ -89,19 +131,19 @@ class Deployment:
|
||||
|
||||
@property
|
||||
def artifact_id(self) -> str:
|
||||
return self.model.artifact_id
|
||||
return self._model.artifact_id
|
||||
|
||||
@property
|
||||
def artifact_version(self) -> int:
|
||||
return self.model.artifact_version
|
||||
return self._model.artifact_version
|
||||
|
||||
@property
|
||||
def bindings(self) -> dict[str, str]:
|
||||
return self.model.binding_map()
|
||||
return self._model.binding_map()
|
||||
|
||||
@property
|
||||
def drift_policy(self) -> DriftPolicy:
|
||||
return self.model.drift_policy
|
||||
return self._model.drift_policy
|
||||
|
||||
async def validate(self) -> DeploymentValidation:
|
||||
payload = await self._port.validate_deployment(
|
||||
@@ -171,7 +213,10 @@ class Deployment:
|
||||
if decoded.run_id is None or decoded.status in {"unrunnable", "rejected"}:
|
||||
raise DeploymentNotRunnable(
|
||||
deployment_id=self.deployment_id,
|
||||
diagnostics=decoded.diagnostics,
|
||||
diagnostics=tuple(
|
||||
diagnostic.model_copy(deep=True)
|
||||
for diagnostic in decoded.diagnostics
|
||||
),
|
||||
outcome=decoded.outcome,
|
||||
error=decoded.error,
|
||||
)
|
||||
@@ -194,7 +239,7 @@ async def run_artifact(
|
||||
*,
|
||||
deployment_id: str | None,
|
||||
bindings: Mapping[str, str] | None,
|
||||
drift_policy: DriftPolicy | str,
|
||||
drift_policy: DriftPolicy,
|
||||
) -> Run:
|
||||
"""Apply the artifact's strict deployment-selection policy."""
|
||||
if deployment_id is not None:
|
||||
@@ -211,14 +256,14 @@ async def run_artifact(
|
||||
),
|
||||
)
|
||||
if (
|
||||
deployment.artifact_id != artifact.artifact.id
|
||||
or deployment.artifact_version != artifact.artifact.version
|
||||
deployment.artifact_id != artifact.ref.artifact_id
|
||||
or deployment.artifact_version != artifact.ref.version
|
||||
):
|
||||
raise InvalidResponse(
|
||||
operation="workflow.deployments.inspect",
|
||||
details=(
|
||||
f"deployment {deployment_id!r} does not target artifact "
|
||||
f"{artifact.artifact.id!r} version {artifact.artifact.version}"
|
||||
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
|
||||
),
|
||||
)
|
||||
return await deployment.run(workflow_input)
|
||||
@@ -228,8 +273,8 @@ async def run_artifact(
|
||||
(
|
||||
summary
|
||||
for summary in summaries
|
||||
if summary["artifact_id"] == artifact.artifact.id
|
||||
and summary["artifact_version"] == artifact.artifact.version
|
||||
if summary["artifact_id"] == artifact.ref.artifact_id
|
||||
and summary["artifact_version"] == artifact.ref.version
|
||||
),
|
||||
key=lambda summary: summary["id"],
|
||||
)
|
||||
@@ -237,7 +282,7 @@ async def run_artifact(
|
||||
raise DeploymentRequired(
|
||||
candidate_deployment_ids=tuple(summary["id"] for summary in matches)
|
||||
)
|
||||
default_id = f"{artifact.artifact.id}.v{artifact.artifact.version}.default"
|
||||
default_id = f"{artifact.ref.artifact_id}.v{artifact.ref.version}.default"
|
||||
if not matches:
|
||||
conflicting = next(
|
||||
(
|
||||
@@ -245,8 +290,8 @@ async def run_artifact(
|
||||
for summary in summaries
|
||||
if summary["id"] == default_id
|
||||
and (
|
||||
summary["artifact_id"] != artifact.artifact.id
|
||||
or summary["artifact_version"] != artifact.artifact.version
|
||||
summary["artifact_id"] != artifact.ref.artifact_id
|
||||
or summary["artifact_version"] != artifact.ref.version
|
||||
)
|
||||
),
|
||||
None,
|
||||
@@ -275,14 +320,14 @@ async def run_artifact(
|
||||
),
|
||||
)
|
||||
if (
|
||||
deployment.artifact_id != artifact.artifact.id
|
||||
or deployment.artifact_version != artifact.artifact.version
|
||||
deployment.artifact_id != artifact.ref.artifact_id
|
||||
or deployment.artifact_version != artifact.ref.version
|
||||
):
|
||||
raise InvalidResponse(
|
||||
operation="workflow.deployments.inspect",
|
||||
details=(
|
||||
f"deployment {matches[0]['id']!r} does not target artifact "
|
||||
f"{artifact.artifact.id!r} version {artifact.artifact.version}"
|
||||
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
|
||||
),
|
||||
)
|
||||
return await deployment.run(workflow_input)
|
||||
|
||||
+22
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from wf_artifacts import DependencyDiagnostic
|
||||
@@ -16,7 +17,27 @@ class TransportError(WorkflowClientError):
|
||||
|
||||
|
||||
class ProtocolError(WorkflowClientError):
|
||||
"""The service returned a response that violates its protocol contract."""
|
||||
"""An inspectable JSON-RPC error not covered by a stable public subclass."""
|
||||
|
||||
code: int | str | None
|
||||
message: str
|
||||
data: object
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: int | str | None,
|
||||
message: str,
|
||||
data: object = None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = deepcopy(data)
|
||||
super().__init__(str(self))
|
||||
|
||||
def __str__(self) -> str:
|
||||
if isinstance(self.data, dict) and isinstance(self.data.get("message"), str):
|
||||
return f"{self.message}: {self.data['message']}"
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -8,10 +8,8 @@ from typing import Any, Literal, Protocol
|
||||
from wf_api.models import (
|
||||
CapabilityCallResult,
|
||||
InspectCapabilityResult,
|
||||
ListArtifactsResult,
|
||||
ListCapabilitiesResult,
|
||||
ListDeploymentsResult,
|
||||
ListRunsResult,
|
||||
RunResult,
|
||||
RunTraceResult,
|
||||
SaveArtifactResult,
|
||||
@@ -55,15 +53,6 @@ class WorkflowClientPort(Protocol):
|
||||
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,
|
||||
*,
|
||||
@@ -71,8 +60,6 @@ class WorkflowClientPort(Protocol):
|
||||
version: int,
|
||||
) -> WorkflowArtifactPayload: ...
|
||||
|
||||
async def save_artifact(self, artifact: dict[str, Any]) -> SaveArtifactResult: ...
|
||||
|
||||
async def create_artifact_from_plan(
|
||||
self,
|
||||
*,
|
||||
@@ -117,14 +104,6 @@ class WorkflowClientPort(Protocol):
|
||||
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,
|
||||
*,
|
||||
|
||||
+85
-17
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +13,7 @@ from wf_api import TraceRange
|
||||
from wf_artifacts import DependencyDiagnostic
|
||||
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
|
||||
|
||||
from ._identity import require_response_identity
|
||||
from ._repr import html_repr, short_repr
|
||||
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
|
||||
from .errors import DeploymentNotRunnable, InvalidResponse
|
||||
@@ -85,15 +87,20 @@ def _run_from_decoded(
|
||||
decoded: DecodedRunResult,
|
||||
*,
|
||||
expected_run_id: str | None = None,
|
||||
expected_deployment_id: str | None = None,
|
||||
operation: str = "workflow.runs.inspect",
|
||||
) -> Run:
|
||||
if expected_run_id is not None and decoded.run_id != expected_run_id:
|
||||
raise InvalidResponse(
|
||||
require_response_identity(
|
||||
operation=operation,
|
||||
details=(
|
||||
f"returned run {decoded.run_id!r} does not match requested "
|
||||
f"{expected_run_id!r}"
|
||||
),
|
||||
actual={"run_id": decoded.run_id},
|
||||
expected={"run_id": expected_run_id},
|
||||
)
|
||||
if expected_deployment_id is not None:
|
||||
require_response_identity(
|
||||
operation=operation,
|
||||
actual={"deployment_id": decoded.deployment_id},
|
||||
expected={"deployment_id": expected_deployment_id},
|
||||
)
|
||||
if decoded.run_id is None:
|
||||
raise DeploymentNotRunnable(
|
||||
@@ -115,7 +122,7 @@ def _run_from_decoded(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, init=False)
|
||||
class Run:
|
||||
"""Immutable client snapshot of one durable deployment run."""
|
||||
|
||||
@@ -124,11 +131,53 @@ class Run:
|
||||
deployment_id: str
|
||||
status: str
|
||||
outcome: str | None
|
||||
output: dict[str, Any] | None
|
||||
interrupt: InterruptRequest | None
|
||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
||||
_output: dict[str, Any] | None = field(repr=False)
|
||||
_interrupt: InterruptRequest | None = field(repr=False)
|
||||
_diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
|
||||
trace_count: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
_port: WorkflowClientPort,
|
||||
run_id: str,
|
||||
deployment_id: str,
|
||||
status: str,
|
||||
outcome: str | None,
|
||||
output: dict[str, Any] | None,
|
||||
interrupt: InterruptRequest | None,
|
||||
diagnostics: tuple[DependencyDiagnostic, ...],
|
||||
trace_count: int,
|
||||
) -> None:
|
||||
object.__setattr__(self, "_port", _port)
|
||||
object.__setattr__(self, "run_id", run_id)
|
||||
object.__setattr__(self, "deployment_id", deployment_id)
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "outcome", outcome)
|
||||
object.__setattr__(self, "_output", deepcopy(output))
|
||||
object.__setattr__(self, "_interrupt", deepcopy(interrupt))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_diagnostics",
|
||||
tuple(item.model_copy(deep=True) for item in diagnostics),
|
||||
)
|
||||
object.__setattr__(self, "trace_count", trace_count)
|
||||
|
||||
@property
|
||||
def output(self) -> dict[str, Any] | None:
|
||||
"""Return a defensive copy of the already-loaded workflow output."""
|
||||
return deepcopy(self._output)
|
||||
|
||||
@property
|
||||
def interrupt(self) -> InterruptRequest | None:
|
||||
"""Return a defensive copy of the already-loaded interrupt contract."""
|
||||
return deepcopy(self._interrupt)
|
||||
|
||||
@property
|
||||
def diagnostics(self) -> tuple[DependencyDiagnostic, ...]:
|
||||
"""Return defensive copies of loaded dependency diagnostics."""
|
||||
return tuple(item.model_copy(deep=True) for item in self._diagnostics)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
@@ -136,8 +185,8 @@ class Run:
|
||||
deployment_id=self.deployment_id,
|
||||
status=self.status,
|
||||
outcome=self.outcome,
|
||||
output=self.output,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
output=self._output,
|
||||
diagnostics=f"{len(self._diagnostics)} diagnostics",
|
||||
trace=f"{self.trace_count} frames",
|
||||
)
|
||||
|
||||
@@ -148,8 +197,8 @@ class Run:
|
||||
deployment_id=self.deployment_id,
|
||||
status=self.status,
|
||||
outcome=self.outcome,
|
||||
output=self.output,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
output=self._output,
|
||||
diagnostics=f"{len(self._diagnostics)} diagnostics",
|
||||
trace=f"{self.trace_count} frames (use trace() for a bounded page)",
|
||||
)
|
||||
|
||||
@@ -160,6 +209,7 @@ class Run:
|
||||
payload: object,
|
||||
*,
|
||||
expected_run_id: str | None = None,
|
||||
expected_deployment_id: str | None = None,
|
||||
operation: str = "workflow.runs.inspect",
|
||||
) -> Run:
|
||||
"""Validate one run response and reconstruct its immutable snapshot."""
|
||||
@@ -167,6 +217,7 @@ class Run:
|
||||
port,
|
||||
decode_run_result(payload, operation=operation),
|
||||
expected_run_id=expected_run_id,
|
||||
expected_deployment_id=expected_deployment_id,
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
@@ -176,6 +227,7 @@ class Run:
|
||||
self._port,
|
||||
await self._port.inspect_run(run_id=self.run_id),
|
||||
expected_run_id=self.run_id,
|
||||
expected_deployment_id=self.deployment_id,
|
||||
operation="workflow.runs.inspect",
|
||||
)
|
||||
|
||||
@@ -186,9 +238,9 @@ class Run:
|
||||
outcome: str = "submitted",
|
||||
) -> Run:
|
||||
"""Resume an interrupted run and return the server's new snapshot."""
|
||||
if self.status != "interrupted" or self.interrupt is None:
|
||||
if self.status != "interrupted" or self._interrupt is None:
|
||||
raise ValueError("only interrupted runs can be resumed")
|
||||
if not self.interrupt.resumable:
|
||||
if not self._interrupt.resumable:
|
||||
raise ValueError("run interrupt is not resumable")
|
||||
return self.from_payload(
|
||||
self._port,
|
||||
@@ -198,6 +250,7 @@ class Run:
|
||||
resume_outcome=outcome,
|
||||
),
|
||||
expected_run_id=self.run_id,
|
||||
expected_deployment_id=self.deployment_id,
|
||||
operation="workflow.runs.resume",
|
||||
)
|
||||
|
||||
@@ -213,10 +266,25 @@ class Run:
|
||||
trace_range=TraceRange(start=start, limit=limit),
|
||||
)
|
||||
)
|
||||
require_response_identity(
|
||||
operation="workflow.runs.trace",
|
||||
actual={
|
||||
"run_id": decoded.run_id,
|
||||
"deployment_id": decoded.deployment_id,
|
||||
"trace_start": decoded.trace_start,
|
||||
"trace_limit": decoded.trace_limit,
|
||||
},
|
||||
expected={
|
||||
"run_id": self.run_id,
|
||||
"deployment_id": self.deployment_id,
|
||||
"trace_start": start,
|
||||
"trace_limit": limit,
|
||||
},
|
||||
)
|
||||
frames = tuple(TraceEntry(**dict(frame)) for frame in (decoded.trace or ()))
|
||||
return TracePage(
|
||||
start=decoded.trace_start if decoded.trace_start is not None else start,
|
||||
limit=decoded.trace_limit if decoded.trace_limit is not None else limit,
|
||||
start=start,
|
||||
limit=limit,
|
||||
frames=frames,
|
||||
truncated=bool(decoded.trace_truncated),
|
||||
trace_count=decoded.trace_count,
|
||||
|
||||
+67
-40
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from wf_artifacts.models import (
|
||||
DriftPolicy,
|
||||
RequiredCapability,
|
||||
)
|
||||
from wf_artifacts.models import (
|
||||
@@ -14,7 +15,9 @@ from wf_artifacts.models import (
|
||||
)
|
||||
from wf_core import ValidationReport, Workflow
|
||||
|
||||
from ._identity import require_response_identity
|
||||
from ._repr import html_repr, short_repr
|
||||
from .codec import decode_save_deployment
|
||||
from .errors import InvalidResponse, ValidationFailed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -119,17 +122,39 @@ class WorkflowValidation:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, init=False)
|
||||
class WorkflowArtifact:
|
||||
"""Immutable client snapshot retaining the validated artifact and workflow."""
|
||||
|
||||
_port: WorkflowClientPort = field(repr=False, compare=False)
|
||||
artifact: ArtifactDomainModel
|
||||
workflow: Workflow
|
||||
_artifact: ArtifactDomainModel = field(repr=False)
|
||||
_workflow: Workflow = field(repr=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: WorkflowClientPort,
|
||||
artifact: ArtifactDomainModel,
|
||||
workflow: Workflow,
|
||||
) -> None:
|
||||
# Frozen dataclasses do not recursively freeze Pydantic models. Retain
|
||||
# private deep copies and expose only defensive projections below.
|
||||
object.__setattr__(self, "_port", port)
|
||||
object.__setattr__(self, "_artifact", artifact.model_copy(deep=True))
|
||||
object.__setattr__(self, "_workflow", workflow.model_copy(deep=True))
|
||||
|
||||
@property
|
||||
def artifact(self) -> ArtifactDomainModel:
|
||||
"""Return a defensive copy of the validated artifact envelope."""
|
||||
return self._artifact.model_copy(deep=True)
|
||||
|
||||
@property
|
||||
def workflow(self) -> Workflow:
|
||||
"""Return a defensive copy of the executable workflow."""
|
||||
return self._workflow.model_copy(deep=True)
|
||||
|
||||
@property
|
||||
def ref(self) -> ArtifactRef:
|
||||
return ArtifactRef(self.artifact.id, self.artifact.version)
|
||||
return ArtifactRef(self._artifact.id, self._artifact.version)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
@@ -150,28 +175,28 @@ class WorkflowArtifact:
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.artifact.title
|
||||
return self._artifact.title
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
return self.artifact.description
|
||||
return self._artifact.description
|
||||
|
||||
@property
|
||||
def required_capabilities(self) -> tuple[RequiredCapability, ...]:
|
||||
return tuple(
|
||||
capability
|
||||
capability.model_copy(deep=True)
|
||||
if isinstance(capability, RequiredCapability)
|
||||
else RequiredCapability.model_validate(capability)
|
||||
for capability in self.artifact.required_capabilities
|
||||
for capability in self._artifact.required_capabilities
|
||||
)
|
||||
|
||||
@property
|
||||
def workflow_dependencies(self) -> dict[str, int]:
|
||||
return dict(self.artifact.workflow_dependencies)
|
||||
return dict(self._artifact.workflow_dependencies)
|
||||
|
||||
def inspect(self) -> Workflow:
|
||||
"""Return a deep copy so inspecting an artifact cannot mutate its snapshot."""
|
||||
return self.workflow.model_copy(deep=True)
|
||||
return self._workflow.model_copy(deep=True)
|
||||
|
||||
def edit(self) -> EditableWorkflow:
|
||||
"""Seed an editable builder from this exact immutable artifact version."""
|
||||
@@ -185,34 +210,37 @@ class WorkflowArtifact:
|
||||
deployment_id: str,
|
||||
*,
|
||||
bindings: Mapping[str, str] | None = None,
|
||||
drift_policy: str = "block",
|
||||
drift_policy: DriftPolicy = DriftPolicy.BLOCK,
|
||||
) -> Deployment:
|
||||
"""Save, inspect, and validate a deployment for this artifact version."""
|
||||
from .deployments import Deployment
|
||||
|
||||
saved = await self._port.save_deployment(
|
||||
{
|
||||
"id": deployment_id,
|
||||
"artifact_id": self.artifact.id,
|
||||
"artifact_version": self.artifact.version,
|
||||
"bindings": dict(bindings or {}),
|
||||
"drift_policy": drift_policy,
|
||||
}
|
||||
saved = decode_save_deployment(
|
||||
await self._port.save_deployment(
|
||||
{
|
||||
"id": deployment_id,
|
||||
"artifact_id": self._artifact.id,
|
||||
"artifact_version": self._artifact.version,
|
||||
"bindings": dict(bindings or {}),
|
||||
"drift_policy": drift_policy,
|
||||
}
|
||||
)
|
||||
)
|
||||
require_response_identity(
|
||||
operation="workflow.deployments.save",
|
||||
actual={
|
||||
"deployment_id": saved["deployment_id"],
|
||||
"artifact_id": saved["artifact_id"],
|
||||
"artifact_version": saved["artifact_version"],
|
||||
"saved": saved["saved"],
|
||||
},
|
||||
expected={
|
||||
"deployment_id": deployment_id,
|
||||
"artifact_id": self._artifact.id,
|
||||
"artifact_version": self._artifact.version,
|
||||
"saved": True,
|
||||
},
|
||||
)
|
||||
if (
|
||||
not isinstance(saved, Mapping)
|
||||
or saved.get("deployment_id") != deployment_id
|
||||
):
|
||||
saved_id = (
|
||||
saved.get("deployment_id") if isinstance(saved, Mapping) else None
|
||||
)
|
||||
raise InvalidResponse(
|
||||
operation="workflow.deployments.save",
|
||||
details=(
|
||||
f"saved deployment id {saved_id!r} does not match requested "
|
||||
f"{deployment_id!r}"
|
||||
),
|
||||
)
|
||||
deployment = Deployment.from_payload(
|
||||
self._port,
|
||||
await self._port.inspect_deployment(deployment_id=deployment_id),
|
||||
@@ -226,19 +254,18 @@ class WorkflowArtifact:
|
||||
),
|
||||
)
|
||||
if (
|
||||
deployment.artifact_id != self.artifact.id
|
||||
or deployment.artifact_version != self.artifact.version
|
||||
deployment.artifact_id != self._artifact.id
|
||||
or deployment.artifact_version != self._artifact.version
|
||||
):
|
||||
raise InvalidResponse(
|
||||
operation="workflow.deployments.inspect",
|
||||
details=(
|
||||
f"deployment {deployment_id!r} does not target artifact "
|
||||
f"{self.artifact.id!r} version {self.artifact.version}"
|
||||
f"{self._artifact.id!r} version {self._artifact.version}"
|
||||
),
|
||||
)
|
||||
validation = await deployment.validate()
|
||||
return replace(
|
||||
deployment,
|
||||
return deployment.with_validation(
|
||||
diagnostics=validation.diagnostics,
|
||||
runnable=validation.runnable,
|
||||
)
|
||||
@@ -249,7 +276,7 @@ class WorkflowArtifact:
|
||||
*,
|
||||
deployment_id: str | None = None,
|
||||
bindings: Mapping[str, str] | None = None,
|
||||
drift_policy: str = "block",
|
||||
drift_policy: DriftPolicy = DriftPolicy.BLOCK,
|
||||
) -> Run:
|
||||
"""Run the artifact under the strict deployment selection policy."""
|
||||
from .deployments import run_artifact
|
||||
|
||||
@@ -22,6 +22,7 @@ def __getattr__(name: str) -> object:
|
||||
return generate_manifest
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContractManifest",
|
||||
"JsonSchema",
|
||||
|
||||
@@ -15,7 +15,9 @@ from .model import ManifestError
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Manage the checked workflow API contract manifest.")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage the checked workflow API contract manifest."
|
||||
)
|
||||
parser.add_argument("command", choices=("write", "check"))
|
||||
return parser
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from uuid import uuid4
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(slots=True)
|
||||
class RpcProtocolError(RuntimeError):
|
||||
"""Structured JSON-RPC application error returned by a remote endpoint.
|
||||
|
||||
@@ -64,6 +64,8 @@ class RpcClientTransport:
|
||||
response = await self.http_client.post(self.url, json=request)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("JSON-RPC response must be an object")
|
||||
if "error" in payload:
|
||||
error = payload["error"]
|
||||
if not isinstance(error, dict):
|
||||
|
||||
Reference in New Issue
Block a user