feat: validate workflow plans without saving
This commit is contained in:
@@ -218,6 +218,45 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"ArtifactPlanDiagnosticPayload": {
|
||||||
|
"description": "Stable diagnostic projected when an artifact plan is invalid.",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"repair_hint": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"enum": [
|
||||||
|
"error",
|
||||||
|
"warning"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"severity",
|
||||||
|
"code",
|
||||||
|
"path",
|
||||||
|
"message",
|
||||||
|
"repair_hint"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AuthRecordSummaryPayload": {
|
"AuthRecordSummaryPayload": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Auth record summary without credential payload values.\n\n``metadata`` is explicitly non-secret display data. Credential material\nbelongs in the omitted auth payload and is represented only by key names.",
|
"description": "Auth record summary without credential payload values.\n\n``metadata`` is explicitly non-secret display data. Credential material\nbelongs in the omitted auth payload and is represented only by key names.",
|
||||||
@@ -4387,6 +4426,43 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ValidateArtifactPlanResult": {
|
||||||
|
"description": "Non-persisting artifact-plan validation and dependency inventory.",
|
||||||
|
"properties": {
|
||||||
|
"diagnostics": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ArtifactPlanDiagnosticPayload"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"required_capabilities": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RequiredCapabilityPayload"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"valid",
|
||||||
|
"invalid"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workflow_dependencies": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"diagnostics",
|
||||||
|
"required_capabilities",
|
||||||
|
"workflow_dependencies"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ValidateDeploymentResult": {
|
"ValidateDeploymentResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"artifact_id": {
|
"artifact_id": {
|
||||||
@@ -5714,6 +5790,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"action": "validate_plan",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/errors/5000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "workflow.artifacts.validate_plan",
|
||||||
|
"namespace": [
|
||||||
|
"workflow",
|
||||||
|
"artifacts"
|
||||||
|
],
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": "plan",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "outcomes",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "required_capabilities",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_bindings",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ValidateArtifactPlanResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"action": "call",
|
"action": "call",
|
||||||
"errors": [
|
"errors": [
|
||||||
|
|||||||
+128
-13
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
@@ -25,6 +27,7 @@ from .capability_requirements import observed_node_specs
|
|||||||
from .drafts import WorkflowDraftApi
|
from .drafts import WorkflowDraftApi
|
||||||
from .listing import matches_query, paged_list_payload
|
from .listing import matches_query, paged_list_payload
|
||||||
from .models import (
|
from .models import (
|
||||||
|
ArtifactPlanDiagnosticPayload,
|
||||||
CreateArtifactFromWorkspaceResult,
|
CreateArtifactFromWorkspaceResult,
|
||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
JsonProjector,
|
JsonProjector,
|
||||||
@@ -33,6 +36,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SavedDraftArtifactResult,
|
SavedDraftArtifactResult,
|
||||||
UnsavedDraftArtifactResult,
|
UnsavedDraftArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from .operation_context import WorkflowOperationContext
|
from .operation_context import WorkflowOperationContext
|
||||||
@@ -41,10 +45,76 @@ _PROJECT_ARTIFACT = JsonProjector(WorkflowArtifactPayload)
|
|||||||
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
|
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
|
||||||
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
|
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
|
||||||
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
|
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
|
||||||
|
_PROJECT_VALIDATE_ARTIFACT = JsonProjector(ValidateArtifactPlanResult)
|
||||||
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
|
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
|
||||||
_PROJECT_SAVED_DRAFT_ARTIFACT = JsonProjector(SavedDraftArtifactResult)
|
_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:
|
class WorkflowArtifactApi:
|
||||||
"""Saved workflow artifact operations.
|
"""Saved workflow artifact operations.
|
||||||
|
|
||||||
@@ -133,25 +203,17 @@ class WorkflowArtifactApi:
|
|||||||
source_bindings: dict[str, str] | None = None,
|
source_bindings: dict[str, str] | None = None,
|
||||||
created_from_catalog_version: str | None = None,
|
created_from_catalog_version: str | None = None,
|
||||||
) -> SaveArtifactResult:
|
) -> SaveArtifactResult:
|
||||||
typed_plan = (
|
workflow_artifact = _prepare_artifact_from_plan(
|
||||||
plan
|
self.context,
|
||||||
if isinstance(plan, RawWorkflowPlan)
|
|
||||||
else RawWorkflowPlan.model_validate(plan)
|
|
||||||
)
|
|
||||||
workflow_artifact = build_workflow_artifact_from_plan(
|
|
||||||
artifact_id=artifact_id,
|
artifact_id=artifact_id,
|
||||||
version=version,
|
version=version,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
description=description,
|
description=description,
|
||||||
plan=typed_plan.model_dump(mode="json", by_alias=True),
|
plan=plan,
|
||||||
outcomes=tuple(outcomes),
|
outcomes=outcomes,
|
||||||
required_capabilities={
|
required_capabilities=required_capabilities,
|
||||||
name: RequiredCapability.model_validate(capability)
|
|
||||||
for name, capability in (required_capabilities or {}).items()
|
|
||||||
},
|
|
||||||
source_bindings=source_bindings,
|
source_bindings=source_bindings,
|
||||||
observed_node_specs=observed_node_specs(self.context),
|
|
||||||
created_from_catalog_version=created_from_catalog_version,
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
)
|
)
|
||||||
self._artifact_store().save_artifact(workflow_artifact)
|
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(
|
async def create_artifact_from_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ from .admin import (
|
|||||||
from .artifacts import (
|
from .artifacts import (
|
||||||
ArtifactCatalogEntryPayload,
|
ArtifactCatalogEntryPayload,
|
||||||
ArtifactKindPayload,
|
ArtifactKindPayload,
|
||||||
|
ArtifactPlanDiagnosticPayload,
|
||||||
CapabilityKindPayload,
|
CapabilityKindPayload,
|
||||||
CapabilityRefPayload,
|
CapabilityRefPayload,
|
||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
RequiredCapabilityPayload,
|
RequiredCapabilityPayload,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from .authoring_contracts import (
|
from .authoring_contracts import (
|
||||||
@@ -139,6 +141,7 @@ __all__ = [
|
|||||||
"AuthoringStepContractPayload",
|
"AuthoringStepContractPayload",
|
||||||
"AuthRecordSummaryPayload",
|
"AuthRecordSummaryPayload",
|
||||||
"ArtifactCatalogEntryPayload",
|
"ArtifactCatalogEntryPayload",
|
||||||
|
"ArtifactPlanDiagnosticPayload",
|
||||||
"ArtifactKindPayload",
|
"ArtifactKindPayload",
|
||||||
"CapabilityKindPayload",
|
"CapabilityKindPayload",
|
||||||
"CapabilityCallResult",
|
"CapabilityCallResult",
|
||||||
@@ -205,6 +208,7 @@ __all__ = [
|
|||||||
"RequiredCapabilityPayload",
|
"RequiredCapabilityPayload",
|
||||||
"RemoveRegistryEntryResult",
|
"RemoveRegistryEntryResult",
|
||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
|
"ValidateArtifactPlanResult",
|
||||||
"SavedDraftArtifactResult",
|
"SavedDraftArtifactResult",
|
||||||
"SaveDeploymentResult",
|
"SaveDeploymentResult",
|
||||||
"SourceBindingPayload",
|
"SourceBindingPayload",
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ class SaveArtifactResult(TypedDict):
|
|||||||
saved: bool
|
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):
|
class DeleteArtifactResult(TypedDict):
|
||||||
artifact_id: str
|
artifact_id: str
|
||||||
version: int
|
version: int
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SavedDraftArtifactResult,
|
SavedDraftArtifactResult,
|
||||||
SaveDeploymentResult,
|
SaveDeploymentResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
ValidateDraftResult,
|
ValidateDraftResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
@@ -217,6 +218,21 @@ class WorkflowApi:
|
|||||||
created_from_catalog_version=created_from_catalog_version,
|
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(
|
async def create_artifact_from_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SaveDeploymentResult,
|
SaveDeploymentResult,
|
||||||
SourceDiagnosisResult,
|
SourceDiagnosisResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
ValidateDraftResult,
|
ValidateDraftResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
@@ -459,6 +460,15 @@ class WorkflowArtifactSurface(Protocol):
|
|||||||
created_from_catalog_version: str | None = None,
|
created_from_catalog_version: str | None = None,
|
||||||
) -> SaveArtifactResult: ...
|
) -> 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):
|
class WorkflowDeploymentSurface(Protocol):
|
||||||
"""Deployment methods exposed by workflow frontends."""
|
"""Deployment methods exposed by workflow frontends."""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from wf_api.models import (
|
|||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
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(
|
async def create_artifact_from_plan(
|
||||||
self: RpcCaller,
|
self: RpcCaller,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from wf_api.models import (
|
|||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from wf_server import WorkflowServer
|
from wf_server import WorkflowServer
|
||||||
@@ -21,6 +22,7 @@ from ..models import (
|
|||||||
InspectArtifactParams,
|
InspectArtifactParams,
|
||||||
ListArtifactsParams,
|
ListArtifactsParams,
|
||||||
SaveArtifactParams,
|
SaveArtifactParams,
|
||||||
|
ValidateArtifactPlanParams,
|
||||||
)
|
)
|
||||||
from ..params import RpcParams
|
from ..params import RpcParams
|
||||||
|
|
||||||
@@ -63,6 +65,22 @@ def register_methods(
|
|||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
raise_workflow_rpc_error(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])
|
@entrypoint.method(name="workflow.artifacts.list", errors=[WorkflowRpcError])
|
||||||
async def workflow_artifacts_list(
|
async def workflow_artifacts_list(
|
||||||
params: ListArtifactsParams = RpcParams(),
|
params: ListArtifactsParams = RpcParams(),
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ class SaveArtifactParams(RpcParamsModel):
|
|||||||
artifact: dict[str, Any]
|
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):
|
class SaveDeploymentParams(RpcParamsModel):
|
||||||
deployment: dict[str, Any]
|
deployment: dict[str, Any]
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,57 @@ async def test_create_artifact_from_plan_saves_with_observed_node_specs(
|
|||||||
assert saved.id == "echo"
|
assert saved.id == "echo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_artifact_plan_does_not_persist(tmp_path: Path) -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_validate")
|
||||||
|
api, _service = _artifact_api(artifact_store)
|
||||||
|
|
||||||
|
result = await api.validate_artifact_plan(
|
||||||
|
plan=_echo_artifact().plan,
|
||||||
|
outcomes=("completed",),
|
||||||
|
source_bindings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "valid"
|
||||||
|
assert result["diagnostics"] == []
|
||||||
|
assert await api.list_artifacts(query="echo") == {
|
||||||
|
"nodes": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_artifact_plan_projects_invalid_plan_diagnostic(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_invalid")
|
||||||
|
api, _service = _artifact_api(artifact_store)
|
||||||
|
invalid_plan = {**_echo_artifact().plan, "start": "missing"}
|
||||||
|
|
||||||
|
result = await api.validate_artifact_plan(
|
||||||
|
plan=invalid_plan,
|
||||||
|
outcomes=("completed",),
|
||||||
|
source_bindings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "invalid"
|
||||||
|
assert result["diagnostics"] == [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "artifact_plan_invalid",
|
||||||
|
"path": "plan",
|
||||||
|
"message": "invalid workflow plan: start node 'missing' does not exist",
|
||||||
|
"repair_hint": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert await api.list_artifacts(query="echo") == {
|
||||||
|
"nodes": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
|
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ def test_generates_the_complete_real_workflow_contract() -> None:
|
|||||||
manifest = generate_manifest()
|
manifest = generate_manifest()
|
||||||
schemas = manifest["components"]["schemas"]
|
schemas = manifest["components"]["schemas"]
|
||||||
|
|
||||||
assert len(manifest["operations"]) == 71
|
assert len(manifest["operations"]) == 72
|
||||||
assert len({operation["method"] for operation in manifest["operations"]}) == 71
|
assert len({operation["method"] for operation in manifest["operations"]}) == 72
|
||||||
assert len(schemas) == 140
|
assert len(schemas) == 142
|
||||||
assert len(manifest["components"]["errors"]) == 1
|
assert len(manifest["components"]["errors"]) == 1
|
||||||
assert all(
|
assert all(
|
||||||
set(operation["result"]["schema"]) == {"$ref"}
|
set(operation["result"]["schema"]) == {"$ref"}
|
||||||
|
|||||||
@@ -1333,6 +1333,33 @@ async def test_rpc_create_artifact_from_plan(tmp_path) -> None:
|
|||||||
assert inspected["result"]["plan"]["name"] == "rpc_constant"
|
assert inspected["result"]["plan"]["name"] == "rpc_constant"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_validate_artifact_plan_does_not_persist(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
validated = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
|
{
|
||||||
|
"plan": _constant_plan().model_dump(mode="json", by_alias=True),
|
||||||
|
"outcomes": ["ok"],
|
||||||
|
"source_bindings": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
listed = await _rpc(
|
||||||
|
client, "workflow.artifacts.list", {"query": "rpc_constant"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert validated["result"]["status"] == "valid"
|
||||||
|
assert validated["result"]["diagnostics"] == []
|
||||||
|
assert listed["result"] == {
|
||||||
|
"nodes": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
|
|||||||
@@ -695,6 +695,31 @@ async def test_rpc_client_creates_artifact_from_plan(tmp_path) -> None:
|
|||||||
assert inspected["id"] == "client_plan"
|
assert inspected["id"] == "client_plan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_client_validates_artifact_plan_without_persisting(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as http_client:
|
||||||
|
client = RpcWorkflowApiClient(
|
||||||
|
url="http://test/rpc",
|
||||||
|
timeout_seconds=5,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
validated = await client.validate_artifact_plan(
|
||||||
|
plan=_constant_plan().model_dump(mode="json", by_alias=True),
|
||||||
|
outcomes=("ok",),
|
||||||
|
source_bindings={},
|
||||||
|
)
|
||||||
|
listed = await client.list_artifacts(query="client_constant")
|
||||||
|
|
||||||
|
assert validated["status"] == "valid"
|
||||||
|
assert validated["diagnostics"] == []
|
||||||
|
assert listed["nodes"] == []
|
||||||
|
assert listed["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
|
async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ def test_openrpc_exposes_typed_auth_delete_result(
|
|||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
{"artifact_id", "version", "saved"},
|
{"artifact_id", "version", "saved"},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
|
"ValidateArtifactPlanResult",
|
||||||
|
{"status", "diagnostics", "required_capabilities", "workflow_dependencies"},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"workflow.artifacts.save",
|
"workflow.artifacts.save",
|
||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
|
|||||||
@@ -436,6 +436,6 @@ describe("workflow contract generator", () => {
|
|||||||
|
|
||||||
const generatedSource = await generateWorkflowContractSource(manifestText);
|
const generatedSource = await generateWorkflowContractSource(manifestText);
|
||||||
expect(generatedSource).toBe(checkedSource);
|
expect(generatedSource).toBe(checkedSource);
|
||||||
expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(71);
|
expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(72);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
|
|
||||||
describe("generated workflow contract", () => {
|
describe("generated workflow contract", () => {
|
||||||
it("contains every operation exactly once", () => {
|
it("contains every operation exactly once", () => {
|
||||||
expect(workflowOperationNames).toHaveLength(71);
|
expect(workflowOperationNames).toHaveLength(72);
|
||||||
expect(new Set(workflowOperationNames)).toHaveLength(71);
|
expect(new Set(workflowOperationNames)).toHaveLength(72);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains every authored Effect operation without broadening its boundary", () => {
|
it("contains every authored Effect operation without broadening its boundary", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type WorkflowOperationName =
|
|||||||
| "workflow.artifacts.inspect"
|
| "workflow.artifacts.inspect"
|
||||||
| "workflow.artifacts.list"
|
| "workflow.artifacts.list"
|
||||||
| "workflow.artifacts.save"
|
| "workflow.artifacts.save"
|
||||||
|
| "workflow.artifacts.validate_plan"
|
||||||
| "workflow.capabilities.call"
|
| "workflow.capabilities.call"
|
||||||
| "workflow.capabilities.inspect"
|
| "workflow.capabilities.inspect"
|
||||||
| "workflow.capabilities.list"
|
| "workflow.capabilities.list"
|
||||||
@@ -95,6 +96,7 @@ export const workflowOperationNames: readonly WorkflowOperationName[] = [
|
|||||||
"workflow.artifacts.inspect",
|
"workflow.artifacts.inspect",
|
||||||
"workflow.artifacts.list",
|
"workflow.artifacts.list",
|
||||||
"workflow.artifacts.save",
|
"workflow.artifacts.save",
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
"workflow.capabilities.call",
|
"workflow.capabilities.call",
|
||||||
"workflow.capabilities.inspect",
|
"workflow.capabilities.inspect",
|
||||||
"workflow.capabilities.list",
|
"workflow.capabilities.list",
|
||||||
@@ -380,6 +382,23 @@ export interface WorkflowContractMap {
|
|||||||
};
|
};
|
||||||
result: SaveArtifactResult;
|
result: SaveArtifactResult;
|
||||||
};
|
};
|
||||||
|
"workflow.artifacts.validate_plan": {
|
||||||
|
params: {
|
||||||
|
plan: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
outcomes: string[];
|
||||||
|
required_capabilities?: {
|
||||||
|
[k: string]: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
source_bindings?: {
|
||||||
|
[k: string]: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
result: ValidateArtifactPlanResult;
|
||||||
|
};
|
||||||
"workflow.capabilities.call": {
|
"workflow.capabilities.call": {
|
||||||
params: {
|
params: {
|
||||||
qualified_name: string;
|
qualified_name: string;
|
||||||
@@ -1248,6 +1267,35 @@ export interface ArtifactCatalogEntryPayload {
|
|||||||
version: number;
|
version: number;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Non-persisting artifact-plan validation and dependency inventory.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||||
|
* via the `definition` "ValidateArtifactPlanResult".
|
||||||
|
*/
|
||||||
|
export interface ValidateArtifactPlanResult {
|
||||||
|
diagnostics: ArtifactPlanDiagnosticPayload[];
|
||||||
|
required_capabilities: RequiredCapabilityPayload[];
|
||||||
|
status: "valid" | "invalid";
|
||||||
|
workflow_dependencies: {
|
||||||
|
[k: string]: number;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Stable diagnostic projected when an artifact plan is invalid.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||||
|
* via the `definition` "ArtifactPlanDiagnosticPayload".
|
||||||
|
*/
|
||||||
|
export interface ArtifactPlanDiagnosticPayload {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
path: string;
|
||||||
|
repair_hint: string | null;
|
||||||
|
severity: "error" | "warning";
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Outcome returned by a direct node-spec or wrapper capability call.
|
* Outcome returned by a direct node-spec or wrapper capability call.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user