feat: type artifact operation results

This commit is contained in:
lda
2026-07-30 22:31:00 +07:00 Verified
parent 93e690c4d0
commit eae6367d99
10 changed files with 323 additions and 85 deletions
+3 -3
View File
@@ -71,9 +71,9 @@
- A 2026-07-30 spike confirmed that `fastapi-jsonrpc` already exports a
complete OpenRPC document for all 70 registered methods. Request payloads
retain useful Pydantic schemas, so OpenRPC is a viable transport input.
- The first typed-result slice now gives `workflow.health` and all deployment
and run operations named transport-neutral result schemas: 11 of 70
methods. The remaining 59 success results still collapse to generic objects
- Typed-result slices now give `workflow.health` and all artifact, deployment,
and run operations named transport-neutral result schemas: 16 of 70
methods. The remaining 54 success results still collapse to generic objects
because their Python API and JSON-RPC handlers return `dict[str, Any]`.
Continue introducing operation result DTOs before adopting generated
TypeScript contracts.
+57 -31
View File
@@ -24,9 +24,21 @@ from .artifact_refs import artifact_capability_id
from .capability_requirements import observed_node_specs
from .drafts import WorkflowDraftApi
from .listing import matches_query, paged_list_payload
from .models import RawWorkflowPlan
from .models import (
DeleteArtifactResult,
JsonProjector,
ListArtifactsResult,
RawWorkflowPlan,
SaveArtifactResult,
WorkflowArtifactPayload,
)
from .operation_context import WorkflowOperationContext
_PROJECT_ARTIFACT = JsonProjector(WorkflowArtifactPayload)
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
class WorkflowArtifactApi:
"""Saved workflow artifact operations.
@@ -51,14 +63,16 @@ class WorkflowArtifactApi:
kind: ArtifactKind | None = None,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
) -> ListArtifactsResult:
"""Return compact paged saved artifact summaries.
Saved artifacts can contain full raw workflow plans, so list results
deliberately stay summary-only. Use inspect/run tools for detail.
"""
if self.context.artifact_store is None:
return paged_list_payload("nodes", [], cursor=cursor, limit=limit)
return _PROJECT_ARTIFACT_LIST(
paged_list_payload("nodes", [], cursor=cursor, limit=limit)
)
entries = [
artifact_catalog_entry(artifact).model_dump(mode="json")
for artifact in self.context.artifact_store.list_artifacts()
@@ -77,9 +91,13 @@ class WorkflowArtifactApi:
)
]
entries.sort(key=lambda entry: str(entry.get("name", "")))
return paged_list_payload("nodes", entries, cursor=cursor, limit=limit)
return _PROJECT_ARTIFACT_LIST(
paged_list_payload("nodes", entries, cursor=cursor, limit=limit)
)
async def save_artifact(self, artifact: dict[str, Any]) -> dict[str, Any]:
async def save_artifact(
self, artifact: dict[str, Any]
) -> SaveArtifactResult:
workflow_artifact = WorkflowArtifact.model_validate(artifact)
self._artifact_store().save_artifact(workflow_artifact)
self.context.events.record_workflow_event(
@@ -90,11 +108,13 @@ class WorkflowArtifactApi:
"version": workflow_artifact.version,
},
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
return _PROJECT_ARTIFACT_SAVE(
{
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
)
async def create_artifact_from_plan(
self,
@@ -109,7 +129,7 @@ class WorkflowArtifactApi:
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
) -> SaveArtifactResult:
typed_plan = (
plan
if isinstance(plan, RawWorkflowPlan)
@@ -141,11 +161,13 @@ class WorkflowArtifactApi:
"created_from_plan": True,
},
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
return _PROJECT_ARTIFACT_SAVE(
{
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
)
async def create_artifact_from_draft(
self,
@@ -274,35 +296,39 @@ class WorkflowArtifactApi:
async def inspect_artifact(
self, *, artifact_id: str, version: int
) -> dict[str, Any]:
) -> WorkflowArtifactPayload:
artifact = self._artifact_store().get_artifact(artifact_id, version)
return artifact.model_dump(mode="json")
return _PROJECT_ARTIFACT(artifact.model_dump(mode="json"))
async def delete_artifact(
self, *, artifact_id: str, version: int
) -> dict[str, Any]:
) -> DeleteArtifactResult:
store = self._artifact_store()
blockers = store.deployments_for_artifact(artifact_id, version)
blocker_ids = [deployment.id for deployment in blockers]
if blocker_ids:
return {
"artifact_id": artifact_id,
"version": version,
"deleted": False,
"blocked_by_deployments": blocker_ids,
}
return _PROJECT_ARTIFACT_DELETE(
{
"artifact_id": artifact_id,
"version": version,
"deleted": False,
"blocked_by_deployments": blocker_ids,
}
)
store.delete_artifact(artifact_id, version)
self.context.events.record_workflow_event(
"workflow_artifact_deleted",
capability_id=f"{artifact_id}@{version}",
payload={"artifact_id": artifact_id, "version": version},
)
return {
"artifact_id": artifact_id,
"version": version,
"deleted": True,
"blocked_by_deployments": [],
}
return _PROJECT_ARTIFACT_DELETE(
{
"artifact_id": artifact_id,
"version": version,
"deleted": True,
"blocked_by_deployments": [],
}
)
def _suggested_self_bindings(
+22
View File
@@ -1,5 +1,16 @@
"""Transport-neutral workflow API models."""
from .artifacts import (
ArtifactCatalogEntryPayload,
ArtifactKindPayload,
CapabilityKindPayload,
CapabilityRefPayload,
DeleteArtifactResult,
ListArtifactsResult,
RequiredCapabilityPayload,
SaveArtifactResult,
WorkflowArtifactPayload,
)
from .common import (
ArtifactVersionPayload,
DependencyDiagnosticPayload,
@@ -10,6 +21,7 @@ from .common import (
JsonSchema,
NextActionPatchExamplePayload,
NextActionsPayload,
PageMetadataPayload,
RawWorkflowPlan,
TraceRange,
)
@@ -37,6 +49,11 @@ from .runs import (
__all__ = [
"ArtifactVersionPayload",
"ArtifactCatalogEntryPayload",
"ArtifactKindPayload",
"CapabilityKindPayload",
"CapabilityRefPayload",
"DeleteArtifactResult",
"DeleteDeploymentResult",
"DependencyDiagnosticPayload",
"DeploymentSummary",
@@ -48,20 +65,25 @@ __all__ = [
"JsonProjector",
"JsonSchema",
"ListDeploymentsResult",
"ListArtifactsResult",
"ListRunsResult",
"NextActionPatchExamplePayload",
"NextActionsPayload",
"PageMetadataPayload",
"RawWorkflowPlan",
"ResumeReadiness",
"RunResult",
"RunStatus",
"RunSummary",
"RunTraceResult",
"RequiredCapabilityPayload",
"SaveArtifactResult",
"SaveDeploymentResult",
"SourceBindingPayload",
"TraceRange",
"TraceEntryPayload",
"ValidateDeploymentResult",
"WorkflowDeploymentPayload",
"WorkflowArtifactPayload",
"WorkflowRefPayload",
]
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Literal, TypedDict
from .common import (
DependencyDiagnosticPayload,
JsonObject,
PageMetadataPayload,
)
type ArtifactKindPayload = Literal["workflow", "wrapper"]
type CapabilityKindPayload = Literal[
"tool",
"resource",
"prompt",
"node_spec",
"reducer",
"workflow",
]
class CapabilityRefPayload(TypedDict):
source: str
capability_key: str
class RequiredCapabilityPayload(TypedDict):
"""Saved dependency contract for one artifact capability reference."""
ref: CapabilityRefPayload
kind: CapabilityKindPayload
input_schema_hash: str | None
input_schema_snapshot: JsonObject | None
output_schema_hash: str | None
output_schema_snapshot: JsonObject | None
observed_concrete_source: str | None
observed_at_epoch_ms: int | None
class ArtifactCatalogEntryPayload(TypedDict):
"""Compact artifact row returned by discovery operations."""
name: str
artifact_id: str
version: int
kind: str
display_name: str
description: str | None
outcomes: list[str]
input_schema: JsonObject
output_schema: JsonObject
required_sources: list[str]
diagnostics: list[DependencyDiagnosticPayload]
class ListArtifactsResult(PageMetadataPayload):
nodes: list[ArtifactCatalogEntryPayload]
class WorkflowArtifactPayload(TypedDict):
"""Normalized immutable artifact returned by inspect operations."""
id: str
version: int
title: str
kind: ArtifactKindPayload
description: str | None
input_schema: JsonObject
output_schema: JsonObject
outcomes: list[str]
plan: JsonObject
required_capabilities: list[RequiredCapabilityPayload]
workflow_dependencies: dict[str, int]
created_from_catalog_version: str | None
class SaveArtifactResult(TypedDict):
artifact_id: str
version: int
saved: bool
class DeleteArtifactResult(TypedDict):
artifact_id: str
version: int
deleted: bool
blocked_by_deployments: list[str]
+7
View File
@@ -73,6 +73,13 @@ class GuidedResultPayload(TypedDict):
next_actions: NextActionsPayload
class PageMetadataPayload(TypedDict):
"""Cursor metadata shared by compact workflow discovery responses."""
next_cursor: str | None
total: int
@dataclass(frozen=True, slots=True)
class TraceRange:
"""Caller-bounded debug trace slice for durable deployment runs."""
+9 -5
View File
@@ -14,14 +14,18 @@ from .draft_authoring import RouteSource, WorkflowDraftAuthoringApi
from .draft_updates import CapabilityStepUpdate
from .drafts import WorkflowDraftApi
from .models import (
DeleteArtifactResult,
DeleteDeploymentResult,
ListArtifactsResult,
ListDeploymentsResult,
ListRunsResult,
RawWorkflowPlan,
RunResult,
RunTraceResult,
SaveArtifactResult,
SaveDeploymentResult,
ValidateDeploymentResult,
WorkflowArtifactPayload,
WorkflowDeploymentPayload,
)
from .operation_context import WorkflowOperationContext
@@ -91,7 +95,7 @@ class WorkflowApi:
kind: ArtifactKind | None = None,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
) -> ListArtifactsResult:
return await self.artifacts.list_artifacts(
query=query,
kind=kind,
@@ -104,7 +108,7 @@ class WorkflowApi:
*,
artifact_id: str,
version: int,
) -> dict[str, Any]:
) -> WorkflowArtifactPayload:
return await self.artifacts.inspect_artifact(
artifact_id=artifact_id,
version=version,
@@ -115,7 +119,7 @@ class WorkflowApi:
*,
artifact_id: str,
version: int,
) -> dict[str, Any]:
) -> DeleteArtifactResult:
return await self.artifacts.delete_artifact(
artifact_id=artifact_id,
version=version,
@@ -124,7 +128,7 @@ class WorkflowApi:
async def save_artifact(
self,
artifact: dict[str, Any],
) -> dict[str, Any]:
) -> SaveArtifactResult:
return await self.artifacts.save_artifact(artifact)
async def create_artifact_from_plan(
@@ -140,7 +144,7 @@ class WorkflowApi:
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
) -> SaveArtifactResult:
return await self.artifacts.create_artifact_from_plan(
artifact_id=artifact_id,
version=version,
+13 -4
View File
@@ -10,13 +10,17 @@ from wf_core.models.steps import InputBinding, OutputBinding
from .draft_authoring import RouteSource
from .draft_updates import CapabilityStepUpdate
from .models import (
DeleteArtifactResult,
DeleteDeploymentResult,
ListArtifactsResult,
ListDeploymentsResult,
ListRunsResult,
RunResult,
RunTraceResult,
SaveArtifactResult,
SaveDeploymentResult,
ValidateDeploymentResult,
WorkflowArtifactPayload,
WorkflowDeploymentPayload,
)
from .runs import TraceRangeLike
@@ -355,21 +359,26 @@ class WorkflowArtifactSurface(Protocol):
kind: ArtifactKind | None = None,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]: ...
) -> ListArtifactsResult: ...
async def inspect_artifact(
self,
*,
artifact_id: str,
version: int,
) -> dict[str, Any]: ...
) -> WorkflowArtifactPayload: ...
async def save_artifact(
self,
artifact: dict[str, Any],
) -> SaveArtifactResult: ...
async def delete_artifact(
self,
*,
artifact_id: str,
version: int,
) -> dict[str, Any]: ...
) -> DeleteArtifactResult: ...
async def create_artifact_from_plan(
self,
@@ -384,7 +393,7 @@ class WorkflowArtifactSurface(Protocol):
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]: ...
) -> SaveArtifactResult: ...
class WorkflowDeploymentSurface(Protocol):
+57 -35
View File
@@ -1,7 +1,14 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, Literal
from typing import Any, Literal, cast
from wf_api.models import (
DeleteArtifactResult,
ListArtifactsResult,
SaveArtifactResult,
WorkflowArtifactPayload,
)
from .base import RpcCaller
@@ -16,36 +23,48 @@ class RpcArtifactClientMixin:
kind: Literal["workflow", "wrapper"] | None = None,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
return await self._call(
"workflow.artifacts.list",
{
"query": query,
"kind": kind,
"cursor": cursor,
"limit": limit,
},
) -> ListArtifactsResult:
return cast(
ListArtifactsResult,
await self._call(
"workflow.artifacts.list",
{
"query": query,
"kind": kind,
"cursor": cursor,
"limit": limit,
},
),
)
async def inspect_artifact(
self: RpcCaller, *, artifact_id: str, version: int
) -> dict[str, Any]:
return await self._call(
"workflow.artifacts.inspect",
{"artifact_id": artifact_id, "version": version},
) -> WorkflowArtifactPayload:
return cast(
WorkflowArtifactPayload,
await self._call(
"workflow.artifacts.inspect",
{"artifact_id": artifact_id, "version": version},
),
)
async def save_artifact(
self: RpcCaller, artifact: dict[str, Any]
) -> dict[str, Any]:
return await self._call("workflow.artifacts.save", {"artifact": artifact})
) -> SaveArtifactResult:
return cast(
SaveArtifactResult,
await self._call("workflow.artifacts.save", {"artifact": artifact}),
)
async def delete_artifact(
self: RpcCaller, *, artifact_id: str, version: int
) -> dict[str, Any]:
return await self._call(
"workflow.artifacts.delete",
{"artifact_id": artifact_id, "version": version},
) -> DeleteArtifactResult:
return cast(
DeleteArtifactResult,
await self._call(
"workflow.artifacts.delete",
{"artifact_id": artifact_id, "version": version},
),
)
async def create_artifact_from_plan(
@@ -61,19 +80,22 @@ class RpcArtifactClientMixin:
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
return await self._call(
"workflow.artifacts.create_from_plan",
{
"artifact_id": artifact_id,
"version": version,
"title": title,
"plan": plan,
"outcomes": list(outcomes),
"kind": kind,
"description": description,
"required_capabilities": required_capabilities,
"source_bindings": source_bindings,
"created_from_catalog_version": created_from_catalog_version,
},
) -> SaveArtifactResult:
return cast(
SaveArtifactResult,
await self._call(
"workflow.artifacts.create_from_plan",
{
"artifact_id": artifact_id,
"version": version,
"title": title,
"plan": plan,
"outcomes": list(outcomes),
"kind": kind,
"description": description,
"required_capabilities": required_capabilities,
"source_bindings": source_bindings,
"created_from_catalog_version": created_from_catalog_version,
},
),
)
+15 -7
View File
@@ -1,9 +1,17 @@
from __future__ import annotations
"""Artifact JSON-RPC method registration.
from typing import Any
Return annotations stay eagerly evaluated because fastapi-jsonrpc captures them
while registering nested handlers for response validation and OpenRPC output.
"""
import fastapi_jsonrpc as jsonrpc
from wf_api.models import (
DeleteArtifactResult,
ListArtifactsResult,
SaveArtifactResult,
WorkflowArtifactPayload,
)
from wf_server import WorkflowServer
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
@@ -29,7 +37,7 @@ def register_methods(
)
async def workflow_artifacts_create_from_plan(
params: CreateArtifactFromPlanParams = RpcParams(),
) -> dict[str, Any]:
) -> SaveArtifactResult:
try:
return await server.api.create_artifact_from_plan(
artifact_id=params.artifact_id,
@@ -49,7 +57,7 @@ def register_methods(
@entrypoint.method(name="workflow.artifacts.save", errors=[WorkflowRpcError])
async def workflow_artifacts_save(
params: SaveArtifactParams = RpcParams(),
) -> dict[str, Any]:
) -> SaveArtifactResult:
try:
return await server.api.save_artifact(params.artifact)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
@@ -58,7 +66,7 @@ def register_methods(
@entrypoint.method(name="workflow.artifacts.list", errors=[WorkflowRpcError])
async def workflow_artifacts_list(
params: ListArtifactsParams = RpcParams(),
) -> dict[str, Any]:
) -> ListArtifactsResult:
try:
return await server.api.list_artifacts(
query=params.query,
@@ -72,7 +80,7 @@ def register_methods(
@entrypoint.method(name="workflow.artifacts.inspect", errors=[WorkflowRpcError])
async def workflow_artifacts_inspect(
params: InspectArtifactParams = RpcParams(),
) -> dict[str, Any]:
) -> WorkflowArtifactPayload:
try:
return await server.api.inspect_artifact(
artifact_id=params.artifact_id,
@@ -84,7 +92,7 @@ def register_methods(
@entrypoint.method(name="workflow.artifacts.delete", errors=[WorkflowRpcError])
async def workflow_artifacts_delete(
params: DeleteArtifactParams = RpcParams(),
) -> dict[str, Any]:
) -> DeleteArtifactResult:
try:
return await server.api.delete_artifact(
artifact_id=params.artifact_id,
@@ -46,6 +46,59 @@ def test_openrpc_exposes_typed_health_result(
)
@pytest.mark.parametrize(
("method_name", "component_name", "properties"),
[
(
"workflow.artifacts.create_from_plan",
"SaveArtifactResult",
{"artifact_id", "version", "saved"},
),
(
"workflow.artifacts.save",
"SaveArtifactResult",
{"artifact_id", "version", "saved"},
),
(
"workflow.artifacts.list",
"ListArtifactsResult",
{"nodes", "next_cursor", "total"},
),
(
"workflow.artifacts.inspect",
"WorkflowArtifactPayload",
{
"id",
"version",
"title",
"kind",
"input_schema",
"output_schema",
"outcomes",
"plan",
},
),
(
"workflow.artifacts.delete",
"DeleteArtifactResult",
{"artifact_id", "version", "deleted", "blocked_by_deployments"},
),
],
)
def test_openrpc_exposes_typed_artifact_results(
openrpc_document: dict[str, Any],
method_name: str,
component_name: str,
properties: set[str],
) -> None:
_assert_result_component(
openrpc_document,
method_name=method_name,
component_name=component_name,
properties=properties,
)
@pytest.mark.parametrize(
("method_name", "component_name", "properties"),
[