feat: bind draft outputs to state
This commit is contained in:
@@ -297,6 +297,65 @@ class WorkflowDraftApi:
|
||||
],
|
||||
)
|
||||
|
||||
async def bind_output_to_state(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_field: str,
|
||||
state_path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Declare a state field from a step output and bind that output to it.
|
||||
|
||||
This is the common draft-authoring repair for validation errors where a
|
||||
step writes to ``state.x`` before ``state_schema.properties.x`` exists.
|
||||
It deliberately edits only one root state field and one step output map.
|
||||
Route changes remain explicit through ``set_draft_route``.
|
||||
"""
|
||||
workspace = self._draft_store().get_workspace(workspace_id)
|
||||
step = _draft_step(workspace.draft, step_id)
|
||||
capability_name = step.get("use")
|
||||
if not isinstance(capability_name, str) or not capability_name:
|
||||
raise ValueError(
|
||||
f"draft step {step_id!r} does not declare a capability use"
|
||||
)
|
||||
|
||||
state_field = _state_root_field(state_path)
|
||||
spec = self.context.specs.get_qualified_spec(capability_name)
|
||||
output_schema = (
|
||||
spec.output_schema_contract or spec.output_model.model_json_schema()
|
||||
)
|
||||
state_schema = workspace.draft.get("state_schema", {})
|
||||
if not isinstance(state_schema, dict):
|
||||
raise ValueError("draft state_schema must be an object")
|
||||
projected = project_output_property_to_state_schema(
|
||||
state_schema=state_schema,
|
||||
output_schema=output_schema,
|
||||
output_field=output_field,
|
||||
state_field=state_field,
|
||||
)
|
||||
output_map = {
|
||||
**self._step_output_map(workspace_id=workspace_id, step_id=step_id),
|
||||
output_field: state_path,
|
||||
}
|
||||
return await self.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/state_schema",
|
||||
"value": projected,
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/steps/{_escape_json_pointer(step_id)}/output",
|
||||
"value": _draft_output_bindings_payload(output_map),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def _step_input_maps(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -379,6 +379,23 @@ class WorkflowApi:
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
async def bind_output_to_state(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_field: str,
|
||||
state_path: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self.drafts.bind_output_to_state(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
output_field=output_field,
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
async def create_minimal_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -124,6 +124,16 @@ class WorkflowDraftSurface(Protocol):
|
||||
state_path: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def bind_output_to_state(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_field: str,
|
||||
state_path: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def validate_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -318,6 +318,47 @@ def add_state_from_output(
|
||||
)
|
||||
|
||||
|
||||
@app.command("bind-output-to-state")
|
||||
def bind_output_to_state(
|
||||
ctx: typer.Context,
|
||||
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
||||
revision: Annotated[
|
||||
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
|
||||
],
|
||||
step_id: Annotated[str, typer.Option("--step", help="Draft step id.")],
|
||||
output_field: Annotated[
|
||||
str,
|
||||
typer.Option("--output", help="Top-level capability output field."),
|
||||
],
|
||||
state_path: Annotated[
|
||||
str,
|
||||
typer.Option("--state", help="Root state path, for example state.after."),
|
||||
],
|
||||
) -> None:
|
||||
"""Declare state schema and bind one step output to that state field.
|
||||
|
||||
This is the common command to run before validation when a step output
|
||||
should write to a new state field. It copies the selected capability output
|
||||
field schema into state_schema and merges the output binding
|
||||
local.<output> -> state.<field>.
|
||||
|
||||
Run `wf draft validate <workspace_id>` after this command.
|
||||
"""
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
run_cli_operation(
|
||||
context,
|
||||
context.handlers.bind_output_to_state(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
output_field=output_field,
|
||||
state_path=state_path,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command("validate")
|
||||
def validate_draft(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -251,6 +251,20 @@ class AddStateFromOutputRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BindOutputToStateRequest(BaseModel):
|
||||
"""Typed MCP request for binding one step output to one root state field."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||
step_id: str = Field(description="Draft step id whose capability output is used.")
|
||||
output_field: str = Field(
|
||||
description="Top-level output field to bind, for example after."
|
||||
)
|
||||
state_path: str = Field(
|
||||
description="Root state path to declare and bind, for example state.after."
|
||||
)
|
||||
|
||||
|
||||
class DeleteDraftWorkspaceRequest(BaseModel):
|
||||
"""Typed MCP request payload for deleting one draft workspace."""
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from wf_mcp.broker.service.workflow_operation_context import context_from_servic
|
||||
|
||||
from .models import (
|
||||
AddStateFromOutputRequest,
|
||||
BindOutputToStateRequest,
|
||||
CallCapabilityResult,
|
||||
CreateArtifactFromWorkspaceRequest,
|
||||
CreateDraftWorkspaceFromCapabilityRequest,
|
||||
@@ -461,6 +462,27 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.bind_output_to_state",
|
||||
title="Bind Output To State",
|
||||
description=(
|
||||
"Declare one root state field from a draft step capability output "
|
||||
"schema and bind local.<output> to that state path."
|
||||
),
|
||||
)
|
||||
async def bind_output_to_state(
|
||||
request: BindOutputToStateRequest,
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.bind_output_to_state(
|
||||
workspace_id=request.workspace_id,
|
||||
revision=request.revision,
|
||||
step_id=request.step_id,
|
||||
output_field=request.output_field,
|
||||
state_path=request.state_path,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.create_minimal_draft_workspace",
|
||||
title="Create Minimal Draft Workspace",
|
||||
|
||||
@@ -161,6 +161,26 @@ class RpcDraftClientMixin:
|
||||
},
|
||||
)
|
||||
|
||||
async def bind_output_to_state(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_field: str,
|
||||
state_path: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.draft_workspaces.bind_output_to_state",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"revision": revision,
|
||||
"step_id": step_id,
|
||||
"output_field": output_field,
|
||||
"state_path": state_path,
|
||||
},
|
||||
)
|
||||
|
||||
async def validate_draft_workspace(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
|
||||
@@ -9,6 +9,7 @@ from wf_server import WorkflowServer
|
||||
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||
from ..models import (
|
||||
AddStateFromOutputParams,
|
||||
BindOutputToStateParams,
|
||||
CreateArtifactFromWorkspaceParams,
|
||||
CreateDraftFromCapabilityParams,
|
||||
CreateWrapperFromWorkspaceParams,
|
||||
@@ -196,6 +197,24 @@ def register_methods(
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.draft_workspaces.bind_output_to_state",
|
||||
errors=[WorkflowRpcError],
|
||||
)
|
||||
async def workflow_draft_workspaces_bind_output_to_state(
|
||||
params: BindOutputToStateParams = RpcParams(),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await server.api.bind_output_to_state(
|
||||
workspace_id=params.workspace_id,
|
||||
revision=params.revision,
|
||||
step_id=params.step_id,
|
||||
output_field=params.output_field,
|
||||
state_path=params.state_path,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.draft_workspaces.validate", errors=[WorkflowRpcError]
|
||||
)
|
||||
|
||||
@@ -149,6 +149,14 @@ class AddStateFromOutputParams(RpcParamsModel):
|
||||
state_path: str = Field(min_length=1)
|
||||
|
||||
|
||||
class BindOutputToStateParams(RpcParamsModel):
|
||||
workspace_id: str = Field(min_length=1)
|
||||
revision: int = Field(ge=1)
|
||||
step_id: str = Field(min_length=1)
|
||||
output_field: str = Field(min_length=1)
|
||||
state_path: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ValidateDraftWorkspaceParams(RpcParamsModel):
|
||||
workspace_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user