feat: expose draft lifecycle rpc methods
This commit is contained in:
@@ -13,6 +13,7 @@ from .models import (
|
|||||||
CreateArtifactFromPlanParams,
|
CreateArtifactFromPlanParams,
|
||||||
CreateArtifactFromWorkspaceParams,
|
CreateArtifactFromWorkspaceParams,
|
||||||
CreateDraftFromCapabilityParams,
|
CreateDraftFromCapabilityParams,
|
||||||
|
CreateEmptyDraftWorkspaceParams,
|
||||||
CreateWrapperFromWorkspaceParams,
|
CreateWrapperFromWorkspaceParams,
|
||||||
DeleteDeploymentParams,
|
DeleteDeploymentParams,
|
||||||
GetDraftWorkspaceParams,
|
GetDraftWorkspaceParams,
|
||||||
@@ -38,8 +39,10 @@ from .models import (
|
|||||||
ResumeRunParams,
|
ResumeRunParams,
|
||||||
SaveArtifactParams,
|
SaveArtifactParams,
|
||||||
SaveDeploymentParams,
|
SaveDeploymentParams,
|
||||||
|
SetDraftContractParams,
|
||||||
SetDraftNameParams,
|
SetDraftNameParams,
|
||||||
SetDraftRouteParams,
|
SetDraftRouteParams,
|
||||||
|
SetDraftStartParams,
|
||||||
SetStepInputMapParams,
|
SetStepInputMapParams,
|
||||||
SetStepOutputMapParams,
|
SetStepOutputMapParams,
|
||||||
SetWorkflowOutputMapParams,
|
SetWorkflowOutputMapParams,
|
||||||
@@ -60,6 +63,7 @@ __all__ = [
|
|||||||
"CreateArtifactFromPlanParams",
|
"CreateArtifactFromPlanParams",
|
||||||
"CreateArtifactFromWorkspaceParams",
|
"CreateArtifactFromWorkspaceParams",
|
||||||
"CreateDraftFromCapabilityParams",
|
"CreateDraftFromCapabilityParams",
|
||||||
|
"CreateEmptyDraftWorkspaceParams",
|
||||||
"CreateWrapperFromWorkspaceParams",
|
"CreateWrapperFromWorkspaceParams",
|
||||||
"DeleteDeploymentParams",
|
"DeleteDeploymentParams",
|
||||||
"GetDraftWorkspaceParams",
|
"GetDraftWorkspaceParams",
|
||||||
@@ -85,8 +89,10 @@ __all__ = [
|
|||||||
"ResumeRunParams",
|
"ResumeRunParams",
|
||||||
"SaveArtifactParams",
|
"SaveArtifactParams",
|
||||||
"SaveDeploymentParams",
|
"SaveDeploymentParams",
|
||||||
|
"SetDraftContractParams",
|
||||||
"SetDraftNameParams",
|
"SetDraftNameParams",
|
||||||
"SetDraftRouteParams",
|
"SetDraftRouteParams",
|
||||||
|
"SetDraftStartParams",
|
||||||
"SetStepInputMapParams",
|
"SetStepInputMapParams",
|
||||||
"SetStepOutputMapParams",
|
"SetStepOutputMapParams",
|
||||||
"SetWorkflowOutputMapParams",
|
"SetWorkflowOutputMapParams",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..models import (
|
|||||||
CompileDraftWorkspaceParams,
|
CompileDraftWorkspaceParams,
|
||||||
CreateArtifactFromWorkspaceParams,
|
CreateArtifactFromWorkspaceParams,
|
||||||
CreateDraftFromCapabilityParams,
|
CreateDraftFromCapabilityParams,
|
||||||
|
CreateEmptyDraftWorkspaceParams,
|
||||||
CreateWrapperFromWorkspaceParams,
|
CreateWrapperFromWorkspaceParams,
|
||||||
DeleteDraftWorkspaceParams,
|
DeleteDraftWorkspaceParams,
|
||||||
GetDraftWorkspaceParams,
|
GetDraftWorkspaceParams,
|
||||||
@@ -26,8 +27,10 @@ from ..models import (
|
|||||||
RemoveDraftBindingParams,
|
RemoveDraftBindingParams,
|
||||||
RemoveDraftRouteParams,
|
RemoveDraftRouteParams,
|
||||||
RemoveDraftStepParams,
|
RemoveDraftStepParams,
|
||||||
|
SetDraftContractParams,
|
||||||
SetDraftNameParams,
|
SetDraftNameParams,
|
||||||
SetDraftRouteParams,
|
SetDraftRouteParams,
|
||||||
|
SetDraftStartParams,
|
||||||
SetStepInputMapParams,
|
SetStepInputMapParams,
|
||||||
SetStepOutputMapParams,
|
SetStepOutputMapParams,
|
||||||
SetWorkflowOutputMapParams,
|
SetWorkflowOutputMapParams,
|
||||||
@@ -105,6 +108,26 @@ 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.draft_workspaces.create_empty",
|
||||||
|
errors=[WorkflowRpcError],
|
||||||
|
)
|
||||||
|
async def workflow_draft_workspaces_create_empty(
|
||||||
|
params: CreateEmptyDraftWorkspaceParams = RpcParams(),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await server.api.create_empty_draft_workspace(
|
||||||
|
workspace_id=params.workspace_id,
|
||||||
|
name=params.name,
|
||||||
|
title=params.title,
|
||||||
|
input_schema=params.input_schema,
|
||||||
|
state_schema=params.state_schema,
|
||||||
|
output_schema=params.output_schema,
|
||||||
|
outcomes=params.outcomes,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
@entrypoint.method(
|
@entrypoint.method(
|
||||||
name="workflow.draft_workspaces.patch", errors=[WorkflowRpcError]
|
name="workflow.draft_workspaces.patch", errors=[WorkflowRpcError]
|
||||||
)
|
)
|
||||||
@@ -135,6 +158,39 @@ 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.draft_workspaces.set_start", errors=[WorkflowRpcError]
|
||||||
|
)
|
||||||
|
async def workflow_draft_workspaces_set_start(
|
||||||
|
params: SetDraftStartParams = RpcParams(),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await server.api.set_draft_start(
|
||||||
|
workspace_id=params.workspace_id,
|
||||||
|
revision=params.revision,
|
||||||
|
step_id=params.step_id,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
|
@entrypoint.method(
|
||||||
|
name="workflow.draft_workspaces.set_contract", errors=[WorkflowRpcError]
|
||||||
|
)
|
||||||
|
async def workflow_draft_workspaces_set_contract(
|
||||||
|
params: SetDraftContractParams = RpcParams(),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await server.api.set_draft_contract(
|
||||||
|
workspace_id=params.workspace_id,
|
||||||
|
revision=params.revision,
|
||||||
|
input_schema=params.input_schema,
|
||||||
|
state_schema=params.state_schema,
|
||||||
|
output_schema=params.output_schema,
|
||||||
|
outcomes=params.outcomes,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
@entrypoint.method(
|
@entrypoint.method(
|
||||||
name="workflow.draft_workspaces.set_route", errors=[WorkflowRpcError]
|
name="workflow.draft_workspaces.set_route", errors=[WorkflowRpcError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Self
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from wf_api.models import TraceRange
|
from wf_api.models import TraceRange
|
||||||
from wf_artifacts.drafts.models import DraftStep
|
from wf_artifacts.drafts.models import DraftStep
|
||||||
@@ -80,6 +80,31 @@ class CreateDraftFromCapabilityParams(RpcParamsModel):
|
|||||||
error_message_source: Any | None = None
|
error_message_source: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_workflow_outcomes(outcomes: list[str]) -> None:
|
||||||
|
"""Reject outcome lists that cannot form a public workflow contract."""
|
||||||
|
if not outcomes:
|
||||||
|
raise ValueError("workflow outcomes must contain at least one value")
|
||||||
|
if any(not outcome.strip() for outcome in outcomes):
|
||||||
|
raise ValueError("workflow outcomes must not contain blank values")
|
||||||
|
if len(set(outcomes)) != len(outcomes):
|
||||||
|
raise ValueError("workflow outcomes must be unique")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEmptyDraftWorkspaceParams(RpcParamsModel):
|
||||||
|
workspace_id: str = Field(min_length=1)
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
title: str | None = None
|
||||||
|
input_schema: dict[str, Any] | None = None
|
||||||
|
state_schema: dict[str, Any] | None = None
|
||||||
|
output_schema: dict[str, Any] | None = None
|
||||||
|
outcomes: list[str] = Field(default_factory=lambda: ["ok"])
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_outcomes(self) -> Self:
|
||||||
|
_validate_workflow_outcomes(self.outcomes)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class PatchDraftParams(RpcParamsModel):
|
class PatchDraftParams(RpcParamsModel):
|
||||||
draft: dict[str, Any]
|
draft: dict[str, Any]
|
||||||
patch: list[dict[str, Any]]
|
patch: list[dict[str, Any]]
|
||||||
@@ -132,6 +157,41 @@ class SetDraftNameParams(RpcParamsModel):
|
|||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class SetDraftStartParams(RpcParamsModel):
|
||||||
|
workspace_id: str = Field(min_length=1)
|
||||||
|
revision: int = Field(ge=1)
|
||||||
|
step_id: str = Field(min_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_step_id(self) -> Self:
|
||||||
|
if not self.step_id.strip():
|
||||||
|
raise ValueError("draft start step id must not be blank")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SetDraftContractParams(RpcParamsModel):
|
||||||
|
workspace_id: str = Field(min_length=1)
|
||||||
|
revision: int = Field(ge=1)
|
||||||
|
input_schema: dict[str, Any] | None = None
|
||||||
|
state_schema: dict[str, Any] | None = None
|
||||||
|
output_schema: dict[str, Any] | None = None
|
||||||
|
outcomes: list[str] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_contract_edit(self) -> Self:
|
||||||
|
fields = (
|
||||||
|
self.input_schema,
|
||||||
|
self.state_schema,
|
||||||
|
self.output_schema,
|
||||||
|
self.outcomes,
|
||||||
|
)
|
||||||
|
if all(value is None for value in fields):
|
||||||
|
raise ValueError("set_contract requires at least one contract field")
|
||||||
|
if self.outcomes is not None:
|
||||||
|
_validate_workflow_outcomes(self.outcomes)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SetDraftRouteParams(RpcParamsModel):
|
class SetDraftRouteParams(RpcParamsModel):
|
||||||
workspace_id: str = Field(min_length=1)
|
workspace_id: str = Field(min_length=1)
|
||||||
revision: int = Field(ge=1)
|
revision: int = Field(ge=1)
|
||||||
|
|||||||
@@ -316,6 +316,118 @@ async def test_rpc_draft_workspace_methods(tmp_path) -> None:
|
|||||||
assert artifact["result"]["artifact_id"] == "remote_artifact"
|
assert artifact["result"]["artifact_id"] == "remote_artifact"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_draft_workspace_lifecycle_methods(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:
|
||||||
|
created = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.draft_workspaces.create_empty",
|
||||||
|
{
|
||||||
|
"workspace_id": "rpc_control",
|
||||||
|
"name": "rpc_control",
|
||||||
|
"title": "RPC Control",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
started = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.draft_workspaces.set_start",
|
||||||
|
{
|
||||||
|
"workspace_id": "rpc_control",
|
||||||
|
"revision": 1,
|
||||||
|
"step_id": "gate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
contracted = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.draft_workspaces.set_contract",
|
||||||
|
{
|
||||||
|
"workspace_id": "rpc_control",
|
||||||
|
"revision": 2,
|
||||||
|
"state_schema": {"type": "object", "properties": {}},
|
||||||
|
"outcomes": ["error"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inspected = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.draft_workspaces.get",
|
||||||
|
{"workspace_id": "rpc_control", "include_draft": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created["result"]["revision"] == 1
|
||||||
|
assert created["result"]["status"] == "invalid"
|
||||||
|
assert started["result"]["revision"] == 2
|
||||||
|
assert started["result"]["status"] == "invalid"
|
||||||
|
assert contracted["result"]["revision"] == 3
|
||||||
|
assert inspected["result"]["title"] == "RPC Control"
|
||||||
|
assert inspected["result"]["draft"]["start"] == "gate"
|
||||||
|
assert inspected["result"]["draft"]["state_schema"] == {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
assert inspected["result"]["draft"]["outcomes"] == ["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "params"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.set_contract",
|
||||||
|
{"workspace_id": "rpc_control", "revision": 1},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.set_contract",
|
||||||
|
{"workspace_id": "rpc_control", "revision": 1, "outcomes": []},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.set_contract",
|
||||||
|
{
|
||||||
|
"workspace_id": "rpc_control",
|
||||||
|
"revision": 1,
|
||||||
|
"outcomes": ["ok", "ok"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.set_start",
|
||||||
|
{"workspace_id": "rpc_control", "revision": 1, "step_id": " "},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.set_contract",
|
||||||
|
{
|
||||||
|
"workspace_id": "rpc_control",
|
||||||
|
"revision": 1,
|
||||||
|
"state_schema": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_rpc_draft_lifecycle_rejects_invalid_envelope_without_mutation(
|
||||||
|
tmp_path,
|
||||||
|
method: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
await server.api.create_empty_draft_workspace(
|
||||||
|
workspace_id="rpc_control",
|
||||||
|
name="rpc_control",
|
||||||
|
)
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
rejected = await _rpc(client, method, params)
|
||||||
|
inspected = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.draft_workspaces.get",
|
||||||
|
{"workspace_id": "rpc_control", "include_draft": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected["error"]["code"] == -32602
|
||||||
|
assert inspected["result"]["revision"] == 1
|
||||||
|
assert inspected["result"]["draft"]["start"] == ""
|
||||||
|
assert inspected["result"]["draft"]["outcomes"] == ["ok"]
|
||||||
|
|
||||||
|
|
||||||
async def test_rpc_draft_workspace_delete(tmp_path) -> None:
|
async def test_rpc_draft_workspace_delete(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)
|
||||||
|
|||||||
Reference in New Issue
Block a user