feat: expose draft lifecycle rpc methods

This commit is contained in:
lda
2026-07-21 20:17:05 +07:00 Verified
parent ee477c01a4
commit 4c36597f51
4 changed files with 236 additions and 2 deletions
+6
View File
@@ -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]
)
+62 -2
View File
@@ -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)
+112
View File
@@ -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)