feat: expose draft lifecycle rpc methods
This commit is contained in:
@@ -13,6 +13,7 @@ from .models import (
|
||||
CreateArtifactFromPlanParams,
|
||||
CreateArtifactFromWorkspaceParams,
|
||||
CreateDraftFromCapabilityParams,
|
||||
CreateEmptyDraftWorkspaceParams,
|
||||
CreateWrapperFromWorkspaceParams,
|
||||
DeleteDeploymentParams,
|
||||
GetDraftWorkspaceParams,
|
||||
@@ -38,8 +39,10 @@ from .models import (
|
||||
ResumeRunParams,
|
||||
SaveArtifactParams,
|
||||
SaveDeploymentParams,
|
||||
SetDraftContractParams,
|
||||
SetDraftNameParams,
|
||||
SetDraftRouteParams,
|
||||
SetDraftStartParams,
|
||||
SetStepInputMapParams,
|
||||
SetStepOutputMapParams,
|
||||
SetWorkflowOutputMapParams,
|
||||
@@ -60,6 +63,7 @@ __all__ = [
|
||||
"CreateArtifactFromPlanParams",
|
||||
"CreateArtifactFromWorkspaceParams",
|
||||
"CreateDraftFromCapabilityParams",
|
||||
"CreateEmptyDraftWorkspaceParams",
|
||||
"CreateWrapperFromWorkspaceParams",
|
||||
"DeleteDeploymentParams",
|
||||
"GetDraftWorkspaceParams",
|
||||
@@ -85,8 +89,10 @@ __all__ = [
|
||||
"ResumeRunParams",
|
||||
"SaveArtifactParams",
|
||||
"SaveDeploymentParams",
|
||||
"SetDraftContractParams",
|
||||
"SetDraftNameParams",
|
||||
"SetDraftRouteParams",
|
||||
"SetDraftStartParams",
|
||||
"SetStepInputMapParams",
|
||||
"SetStepOutputMapParams",
|
||||
"SetWorkflowOutputMapParams",
|
||||
|
||||
@@ -16,6 +16,7 @@ from ..models import (
|
||||
CompileDraftWorkspaceParams,
|
||||
CreateArtifactFromWorkspaceParams,
|
||||
CreateDraftFromCapabilityParams,
|
||||
CreateEmptyDraftWorkspaceParams,
|
||||
CreateWrapperFromWorkspaceParams,
|
||||
DeleteDraftWorkspaceParams,
|
||||
GetDraftWorkspaceParams,
|
||||
@@ -26,8 +27,10 @@ from ..models import (
|
||||
RemoveDraftBindingParams,
|
||||
RemoveDraftRouteParams,
|
||||
RemoveDraftStepParams,
|
||||
SetDraftContractParams,
|
||||
SetDraftNameParams,
|
||||
SetDraftRouteParams,
|
||||
SetDraftStartParams,
|
||||
SetStepInputMapParams,
|
||||
SetStepOutputMapParams,
|
||||
SetWorkflowOutputMapParams,
|
||||
@@ -105,6 +108,26 @@ def register_methods(
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as 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(
|
||||
name="workflow.draft_workspaces.patch", errors=[WorkflowRpcError]
|
||||
)
|
||||
@@ -135,6 +158,39 @@ def register_methods(
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as 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(
|
||||
name="workflow.draft_workspaces.set_route", errors=[WorkflowRpcError]
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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_artifacts.drafts.models import DraftStep
|
||||
@@ -80,6 +80,31 @@ class CreateDraftFromCapabilityParams(RpcParamsModel):
|
||||
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):
|
||||
draft: dict[str, Any]
|
||||
patch: list[dict[str, Any]]
|
||||
@@ -132,6 +157,41 @@ class SetDraftNameParams(RpcParamsModel):
|
||||
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):
|
||||
workspace_id: str = Field(min_length=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"
|
||||
|
||||
|
||||
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:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
app = create_rpc_app(server)
|
||||
|
||||
Reference in New Issue
Block a user