feat: validate workflow plans without saving
This commit is contained in:
+128
-13
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_artifacts import (
|
||||
ArtifactKind,
|
||||
RequiredCapability,
|
||||
@@ -25,6 +27,7 @@ from .capability_requirements import observed_node_specs
|
||||
from .drafts import WorkflowDraftApi
|
||||
from .listing import matches_query, paged_list_payload
|
||||
from .models import (
|
||||
ArtifactPlanDiagnosticPayload,
|
||||
CreateArtifactFromWorkspaceResult,
|
||||
DeleteArtifactResult,
|
||||
JsonProjector,
|
||||
@@ -33,6 +36,7 @@ from .models import (
|
||||
SaveArtifactResult,
|
||||
SavedDraftArtifactResult,
|
||||
UnsavedDraftArtifactResult,
|
||||
ValidateArtifactPlanResult,
|
||||
WorkflowArtifactPayload,
|
||||
)
|
||||
from .operation_context import WorkflowOperationContext
|
||||
@@ -41,10 +45,76 @@ _PROJECT_ARTIFACT = JsonProjector(WorkflowArtifactPayload)
|
||||
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
|
||||
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
|
||||
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
|
||||
_PROJECT_VALIDATE_ARTIFACT = JsonProjector(ValidateArtifactPlanResult)
|
||||
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
|
||||
_PROJECT_SAVED_DRAFT_ARTIFACT = JsonProjector(SavedDraftArtifactResult)
|
||||
|
||||
|
||||
def _prepare_artifact_from_plan(
|
||||
context: WorkflowOperationContext,
|
||||
*,
|
||||
artifact_id: str,
|
||||
version: int,
|
||||
title: str,
|
||||
kind: ArtifactKind,
|
||||
description: str | None,
|
||||
plan: RawWorkflowPlan | dict[str, Any],
|
||||
outcomes: Sequence[str],
|
||||
required_capabilities: dict[str, dict[str, Any]] | None,
|
||||
source_bindings: dict[str, str] | None,
|
||||
created_from_catalog_version: str | None,
|
||||
) -> WorkflowArtifact:
|
||||
"""Prepare one artifact through the shared plan normalization seam."""
|
||||
typed_plan = (
|
||||
plan
|
||||
if isinstance(plan, RawWorkflowPlan)
|
||||
else RawWorkflowPlan.model_validate(plan)
|
||||
)
|
||||
return build_workflow_artifact_from_plan(
|
||||
artifact_id=artifact_id,
|
||||
version=version,
|
||||
title=title,
|
||||
kind=kind,
|
||||
description=description,
|
||||
plan=typed_plan.model_dump(mode="json", by_alias=True),
|
||||
outcomes=tuple(outcomes),
|
||||
required_capabilities={
|
||||
name: RequiredCapability.model_validate(capability)
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
},
|
||||
source_bindings=source_bindings,
|
||||
observed_node_specs=observed_node_specs(context),
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
|
||||
|
||||
def _invalid_artifact_plan_payload(
|
||||
diagnostic: ArtifactPlanDiagnosticPayload,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the complete invalid result, including empty derived inventories."""
|
||||
return {
|
||||
"status": "invalid",
|
||||
"diagnostics": [diagnostic],
|
||||
"required_capabilities": [],
|
||||
"workflow_dependencies": {},
|
||||
}
|
||||
|
||||
|
||||
def _diagnostic_from_validation_error(
|
||||
exc: ValidationError,
|
||||
) -> ArtifactPlanDiagnosticPayload:
|
||||
"""Project the first typed model error with a stable plan-rooted path."""
|
||||
error = exc.errors()[0]
|
||||
location = ".".join(str(part) for part in error["loc"])
|
||||
return {
|
||||
"severity": "error",
|
||||
"code": "artifact_plan_invalid",
|
||||
"path": f"plan.{location}" if location else "plan",
|
||||
"message": str(error["msg"]),
|
||||
"repair_hint": None,
|
||||
}
|
||||
|
||||
|
||||
class WorkflowArtifactApi:
|
||||
"""Saved workflow artifact operations.
|
||||
|
||||
@@ -133,25 +203,17 @@ class WorkflowArtifactApi:
|
||||
source_bindings: dict[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> SaveArtifactResult:
|
||||
typed_plan = (
|
||||
plan
|
||||
if isinstance(plan, RawWorkflowPlan)
|
||||
else RawWorkflowPlan.model_validate(plan)
|
||||
)
|
||||
workflow_artifact = build_workflow_artifact_from_plan(
|
||||
workflow_artifact = _prepare_artifact_from_plan(
|
||||
self.context,
|
||||
artifact_id=artifact_id,
|
||||
version=version,
|
||||
title=title,
|
||||
kind=kind,
|
||||
description=description,
|
||||
plan=typed_plan.model_dump(mode="json", by_alias=True),
|
||||
outcomes=tuple(outcomes),
|
||||
required_capabilities={
|
||||
name: RequiredCapability.model_validate(capability)
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
},
|
||||
plan=plan,
|
||||
outcomes=outcomes,
|
||||
required_capabilities=required_capabilities,
|
||||
source_bindings=source_bindings,
|
||||
observed_node_specs=observed_node_specs(self.context),
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
self._artifact_store().save_artifact(workflow_artifact)
|
||||
@@ -172,6 +234,59 @@ class WorkflowArtifactApi:
|
||||
}
|
||||
)
|
||||
|
||||
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:
|
||||
"""Validate and inventory a plan without writing the artifact store."""
|
||||
try:
|
||||
# These identity fields satisfy the shared artifact factory only;
|
||||
# validation never calls the store or emits a saved-artifact event.
|
||||
artifact = _prepare_artifact_from_plan(
|
||||
self.context,
|
||||
artifact_id="__validation__",
|
||||
version=1,
|
||||
title="Validation",
|
||||
kind="workflow",
|
||||
description=None,
|
||||
plan=plan,
|
||||
outcomes=outcomes,
|
||||
required_capabilities=required_capabilities,
|
||||
source_bindings=source_bindings,
|
||||
created_from_catalog_version=None,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return _PROJECT_VALIDATE_ARTIFACT(
|
||||
_invalid_artifact_plan_payload(_diagnostic_from_validation_error(exc))
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _PROJECT_VALIDATE_ARTIFACT(
|
||||
_invalid_artifact_plan_payload(
|
||||
{
|
||||
"severity": "error",
|
||||
"code": "artifact_plan_invalid",
|
||||
"path": "plan",
|
||||
"message": str(exc),
|
||||
"repair_hint": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return _PROJECT_VALIDATE_ARTIFACT(
|
||||
{
|
||||
"status": "valid",
|
||||
"diagnostics": [],
|
||||
"required_capabilities": [
|
||||
capability.model_dump(mode="json")
|
||||
for capability in artifact.required_capability_map().values()
|
||||
],
|
||||
"workflow_dependencies": dict(artifact.workflow_dependencies),
|
||||
}
|
||||
)
|
||||
|
||||
async def create_artifact_from_draft(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -14,12 +14,14 @@ from .admin import (
|
||||
from .artifacts import (
|
||||
ArtifactCatalogEntryPayload,
|
||||
ArtifactKindPayload,
|
||||
ArtifactPlanDiagnosticPayload,
|
||||
CapabilityKindPayload,
|
||||
CapabilityRefPayload,
|
||||
DeleteArtifactResult,
|
||||
ListArtifactsResult,
|
||||
RequiredCapabilityPayload,
|
||||
SaveArtifactResult,
|
||||
ValidateArtifactPlanResult,
|
||||
WorkflowArtifactPayload,
|
||||
)
|
||||
from .authoring_contracts import (
|
||||
@@ -139,6 +141,7 @@ __all__ = [
|
||||
"AuthoringStepContractPayload",
|
||||
"AuthRecordSummaryPayload",
|
||||
"ArtifactCatalogEntryPayload",
|
||||
"ArtifactPlanDiagnosticPayload",
|
||||
"ArtifactKindPayload",
|
||||
"CapabilityKindPayload",
|
||||
"CapabilityCallResult",
|
||||
@@ -205,6 +208,7 @@ __all__ = [
|
||||
"RequiredCapabilityPayload",
|
||||
"RemoveRegistryEntryResult",
|
||||
"SaveArtifactResult",
|
||||
"ValidateArtifactPlanResult",
|
||||
"SavedDraftArtifactResult",
|
||||
"SaveDeploymentResult",
|
||||
"SourceBindingPayload",
|
||||
|
||||
@@ -80,6 +80,25 @@ class SaveArtifactResult(TypedDict):
|
||||
saved: bool
|
||||
|
||||
|
||||
class ArtifactPlanDiagnosticPayload(TypedDict):
|
||||
"""Stable diagnostic projected when an artifact plan is invalid."""
|
||||
|
||||
severity: Literal["error", "warning"]
|
||||
code: str
|
||||
path: str
|
||||
message: str
|
||||
repair_hint: str | None
|
||||
|
||||
|
||||
class ValidateArtifactPlanResult(TypedDict):
|
||||
"""Non-persisting artifact-plan validation and dependency inventory."""
|
||||
|
||||
status: Literal["valid", "invalid"]
|
||||
diagnostics: list[ArtifactPlanDiagnosticPayload]
|
||||
required_capabilities: list[RequiredCapabilityPayload]
|
||||
workflow_dependencies: dict[str, int]
|
||||
|
||||
|
||||
class DeleteArtifactResult(TypedDict):
|
||||
artifact_id: str
|
||||
version: int
|
||||
|
||||
@@ -46,6 +46,7 @@ from .models import (
|
||||
SaveArtifactResult,
|
||||
SavedDraftArtifactResult,
|
||||
SaveDeploymentResult,
|
||||
ValidateArtifactPlanResult,
|
||||
ValidateDeploymentResult,
|
||||
ValidateDraftResult,
|
||||
WorkflowArtifactPayload,
|
||||
@@ -217,6 +218,21 @@ class WorkflowApi:
|
||||
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.artifacts.validate_artifact_plan(
|
||||
plan=plan,
|
||||
outcomes=outcomes,
|
||||
required_capabilities=required_capabilities,
|
||||
source_bindings=source_bindings,
|
||||
)
|
||||
|
||||
async def create_artifact_from_draft(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -45,6 +45,7 @@ from .models import (
|
||||
SaveArtifactResult,
|
||||
SaveDeploymentResult,
|
||||
SourceDiagnosisResult,
|
||||
ValidateArtifactPlanResult,
|
||||
ValidateDeploymentResult,
|
||||
ValidateDraftResult,
|
||||
WorkflowArtifactPayload,
|
||||
@@ -459,6 +460,15 @@ class WorkflowArtifactSurface(Protocol):
|
||||
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: ...
|
||||
|
||||
|
||||
class WorkflowDeploymentSurface(Protocol):
|
||||
"""Deployment methods exposed by workflow frontends."""
|
||||
|
||||
@@ -7,6 +7,7 @@ from wf_api.models import (
|
||||
DeleteArtifactResult,
|
||||
ListArtifactsResult,
|
||||
SaveArtifactResult,
|
||||
ValidateArtifactPlanResult,
|
||||
WorkflowArtifactPayload,
|
||||
)
|
||||
|
||||
@@ -67,6 +68,27 @@ class RpcArtifactClientMixin:
|
||||
),
|
||||
)
|
||||
|
||||
async def validate_artifact_plan(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
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 cast(
|
||||
ValidateArtifactPlanResult,
|
||||
await self._call(
|
||||
"workflow.artifacts.validate_plan",
|
||||
{
|
||||
"plan": plan,
|
||||
"outcomes": list(outcomes),
|
||||
"required_capabilities": required_capabilities,
|
||||
"source_bindings": source_bindings,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def create_artifact_from_plan(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
|
||||
@@ -10,6 +10,7 @@ from wf_api.models import (
|
||||
DeleteArtifactResult,
|
||||
ListArtifactsResult,
|
||||
SaveArtifactResult,
|
||||
ValidateArtifactPlanResult,
|
||||
WorkflowArtifactPayload,
|
||||
)
|
||||
from wf_server import WorkflowServer
|
||||
@@ -21,6 +22,7 @@ from ..models import (
|
||||
InspectArtifactParams,
|
||||
ListArtifactsParams,
|
||||
SaveArtifactParams,
|
||||
ValidateArtifactPlanParams,
|
||||
)
|
||||
from ..params import RpcParams
|
||||
|
||||
@@ -63,6 +65,22 @@ def register_methods(
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.artifacts.validate_plan", errors=[WorkflowRpcError]
|
||||
)
|
||||
async def workflow_artifacts_validate_plan(
|
||||
params: ValidateArtifactPlanParams = RpcParams(),
|
||||
) -> ValidateArtifactPlanResult:
|
||||
try:
|
||||
return await server.api.validate_artifact_plan(
|
||||
plan=params.plan,
|
||||
outcomes=tuple(params.outcomes),
|
||||
required_capabilities=params.required_capabilities,
|
||||
source_bindings=params.source_bindings,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(name="workflow.artifacts.list", errors=[WorkflowRpcError])
|
||||
async def workflow_artifacts_list(
|
||||
params: ListArtifactsParams = RpcParams(),
|
||||
|
||||
@@ -120,6 +120,13 @@ class SaveArtifactParams(RpcParamsModel):
|
||||
artifact: dict[str, Any]
|
||||
|
||||
|
||||
class ValidateArtifactPlanParams(RpcParamsModel):
|
||||
plan: dict[str, Any]
|
||||
outcomes: list[str]
|
||||
required_capabilities: dict[str, dict[str, Any]] | None = None
|
||||
source_bindings: dict[str, str] | None = None
|
||||
|
||||
|
||||
class SaveDeploymentParams(RpcParamsModel):
|
||||
deployment: dict[str, Any]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user