Files
lda-wf/src/wf_client/workflows.py
T

264 lines
8.1 KiB
Python

"""Immutable workflow artifacts and validation snapshots."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any, Literal
from wf_artifacts.models import (
RequiredCapability,
)
from wf_artifacts.models import (
WorkflowArtifact as ArtifactDomainModel,
)
from wf_core import ValidationReport, Workflow
from ._repr import html_repr, short_repr
from .errors import InvalidResponse, ValidationFailed
if TYPE_CHECKING:
from .authoring import EditableWorkflow
from .deployments import Deployment
from .protocols import WorkflowClientPort
from .runs import Run
@dataclass(frozen=True, slots=True)
class ArtifactRef:
"""Stable identity of one immutable workflow artifact version."""
artifact_id: str
version: int
def __repr__(self) -> str:
return short_repr(
type(self).__name__, artifact_id=self.artifact_id, version=self.version
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__, artifact_id=self.artifact_id, version=self.version
)
@dataclass(frozen=True, slots=True)
class WorkflowDiagnostic:
"""One server-side workflow-plan diagnostic returned by validation."""
severity: str
code: str
path: str
message: str
repair_hint: str | None = None
def __repr__(self) -> str:
return short_repr(
type(self).__name__,
severity=self.severity,
code=self.code,
path=self.path,
message=self.message,
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__,
severity=self.severity,
code=self.code,
path=self.path,
message=self.message,
)
# Keep the short name used by the public design available without requiring a
# second diagnostic implementation.
Diagnostic = WorkflowDiagnostic
@dataclass(frozen=True, slots=True)
class WorkflowValidation:
"""Combined deterministic local and server-side plan validation result."""
local: ValidationReport
remote_status: Literal["valid", "invalid", "not_run"]
remote_diagnostics: tuple[WorkflowDiagnostic, ...]
@property
def ok(self) -> bool:
return self.local.ok and self.remote_status == "valid"
def __repr__(self) -> str:
return short_repr(
type(self).__name__,
ok=self.ok,
remote_status=self.remote_status,
diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__,
ok=self.ok,
remote_status=self.remote_status,
diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
)
def raise_for_errors(self) -> None:
"""Raise a useful error for either local or remote validation failures."""
self.local.raise_for_errors()
if self.remote_status != "invalid":
return
rendered = "\n".join(
f"- [{diagnostic.code}] {diagnostic.path}: {diagnostic.message}"
for diagnostic in self.remote_diagnostics
)
raise ValidationFailed(
"Workflow server validation failed"
+ (f":\n{rendered}" if rendered else ".")
)
@dataclass(frozen=True, slots=True)
class WorkflowArtifact:
"""Immutable client snapshot retaining the validated artifact and workflow."""
_port: WorkflowClientPort = field(repr=False, compare=False)
artifact: ArtifactDomainModel
workflow: Workflow
@property
def ref(self) -> ArtifactRef:
return ArtifactRef(self.artifact.id, self.artifact.version)
def __repr__(self) -> str:
return short_repr(
type(self).__name__,
ref=self.ref,
title=self.title,
required_capabilities=f"{len(self.required_capabilities)} capabilities",
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__,
ref=self.ref,
title=self.title,
description=self.description,
required_capabilities=f"{len(self.required_capabilities)} capabilities",
)
@property
def title(self) -> str:
return self.artifact.title
@property
def description(self) -> str | None:
return self.artifact.description
@property
def required_capabilities(self) -> tuple[RequiredCapability, ...]:
return tuple(
capability
if isinstance(capability, RequiredCapability)
else RequiredCapability.model_validate(capability)
for capability in self.artifact.required_capabilities
)
@property
def workflow_dependencies(self) -> dict[str, int]:
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)
def edit(self) -> EditableWorkflow:
"""Seed an editable builder from this exact immutable artifact version."""
# Import lazily to avoid the authoring/workflows module cycle.
from .authoring import EditableWorkflow
return EditableWorkflow.from_artifact(self)
async def deploy(
self,
deployment_id: str,
*,
bindings: Mapping[str, str] | None = None,
drift_policy: str = "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,
}
)
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),
)
if deployment.deployment_id != deployment_id:
raise InvalidResponse(
operation="workflow.deployments.inspect",
details=(
f"inspected deployment {deployment.deployment_id!r} does not "
f"match requested {deployment_id!r}"
),
)
if (
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}"
),
)
validation = await deployment.validate()
return replace(
deployment,
diagnostics=validation.diagnostics,
runnable=validation.runnable,
)
async def run(
self,
workflow_input: Mapping[str, Any],
*,
deployment_id: str | None = None,
bindings: Mapping[str, str] | None = None,
drift_policy: str = "block",
) -> Run:
"""Run the artifact under the strict deployment selection policy."""
from .deployments import run_artifact
return await run_artifact(
self,
workflow_input,
deployment_id=deployment_id,
bindings=bindings,
drift_policy=drift_policy,
)