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
+151
View File
@@ -218,6 +218,45 @@
],
"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": {
"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.",
@@ -4387,6 +4426,43 @@
],
"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": {
"properties": {
"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",
"errors": [
+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]
+51
View File
@@ -185,6 +185,57 @@ async def test_create_artifact_from_plan_saves_with_observed_node_specs(
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
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
tmp_path: Path,
+3 -3
View File
@@ -90,9 +90,9 @@ def test_generates_the_complete_real_workflow_contract() -> None:
manifest = generate_manifest()
schemas = manifest["components"]["schemas"]
assert len(manifest["operations"]) == 71
assert len({operation["method"] for operation in manifest["operations"]}) == 71
assert len(schemas) == 140
assert len(manifest["operations"]) == 72
assert len({operation["method"] for operation in manifest["operations"]}) == 72
assert len(schemas) == 142
assert len(manifest["components"]["errors"]) == 1
assert all(
set(operation["result"]["schema"]) == {"$ref"}
+27
View File
@@ -1333,6 +1333,33 @@ async def test_rpc_create_artifact_from_plan(tmp_path) -> None:
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:
server = build_local_static_workflow_server(tmp_path / "store")
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"
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:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
@@ -399,6 +399,11 @@ def test_openrpc_exposes_typed_auth_delete_result(
"SaveArtifactResult",
{"artifact_id", "version", "saved"},
),
(
"workflow.artifacts.validate_plan",
"ValidateArtifactPlanResult",
{"status", "diagnostics", "required_capabilities", "workflow_dependencies"},
),
(
"workflow.artifacts.save",
"SaveArtifactResult",
@@ -436,6 +436,6 @@ describe("workflow contract generator", () => {
const generatedSource = await generateWorkflowContractSource(manifestText);
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", () => {
it("contains every operation exactly once", () => {
expect(workflowOperationNames).toHaveLength(71);
expect(new Set(workflowOperationNames)).toHaveLength(71);
expect(workflowOperationNames).toHaveLength(72);
expect(new Set(workflowOperationNames)).toHaveLength(72);
});
it("contains every authored Effect operation without broadening its boundary", () => {
@@ -22,6 +22,7 @@ export type WorkflowOperationName =
| "workflow.artifacts.inspect"
| "workflow.artifacts.list"
| "workflow.artifacts.save"
| "workflow.artifacts.validate_plan"
| "workflow.capabilities.call"
| "workflow.capabilities.inspect"
| "workflow.capabilities.list"
@@ -95,6 +96,7 @@ export const workflowOperationNames: readonly WorkflowOperationName[] = [
"workflow.artifacts.inspect",
"workflow.artifacts.list",
"workflow.artifacts.save",
"workflow.artifacts.validate_plan",
"workflow.capabilities.call",
"workflow.capabilities.inspect",
"workflow.capabilities.list",
@@ -380,6 +382,23 @@ export interface WorkflowContractMap {
};
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": {
params: {
qualified_name: string;
@@ -1248,6 +1267,35 @@ export interface ArtifactCatalogEntryPayload {
version: number;
[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.
*