feat: validate workflow plans without saving

This commit is contained in:
lda
2026-08-31 00:15:41 +07:00 Verified
parent ff01609a2e
commit 15d343f637
17 changed files with 537 additions and 19 deletions
+128 -13
View File
@@ -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,
*,
+4
View File
@@ -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",
+19
View File
@@ -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
+16
View File
@@ -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,
*,
+10
View File
@@ -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(),
+7
View File
@@ -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]