feat: add workflow output draft command

This commit is contained in:
lda
2026-06-29 02:06:47 +07:00 Verified
parent 001c802a21
commit fe63eb08d4
25 changed files with 499 additions and 51 deletions
+52
View File
@@ -299,6 +299,58 @@ class WorkflowDraftApi:
],
)
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
output_bindings: list[dict[str, Any]]
if merge:
workspace = self._draft_store().get_workspace(workspace_id)
remaining = dict(output_map)
output_bindings = []
output_payload = workspace.draft.get("output")
if isinstance(output_payload, list):
for binding in output_payload:
if not isinstance(binding, dict):
continue
source = binding.get("path")
target = binding.get("target")
if isinstance(source, str) and isinstance(target, str):
output_bindings.append(
{
"path": source,
"target": remaining.pop(source, target),
}
)
elif isinstance(target, str) and "value" in binding:
# Literal workflow outputs cannot be represented by the
# path-only CLI map, but --merge must not discard them.
output_bindings.append(dict(binding))
output_bindings.extend(
{"path": source, "target": target}
for source, target in remaining.items()
)
else:
output_bindings = [
{"path": source, "target": target}
for source, target in output_map.items()
]
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/output",
"value": output_bindings,
}
],
)
def _step_input_maps(
self,
*,
+15
View File
@@ -371,6 +371,21 @@ class WorkflowApi:
merge=merge,
)
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
return await self.drafts.set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
merge=merge,
)
async def bind_draft(
self,
*,
+9
View File
@@ -114,6 +114,15 @@ class WorkflowDraftSurface(Protocol):
merge: bool = False,
) -> dict[str, Any]: ...
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]: ...
async def bind_draft(
self,
*,
+54
View File
@@ -330,6 +330,60 @@ def set_step_output_map(
)
@app.command("set-workflow-output")
def set_workflow_output(
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.")
],
mapping: Annotated[
list[str] | None,
typer.Option(
"--map",
help=(
"One output binding GRAPH_SOURCE=OUTPUT_FIELD, for example "
"state.markdown=markdown. Repeat in one command."
),
),
] = None,
merge: Annotated[
bool,
typer.Option(
"--merge",
help="Preserve existing workflow output bindings and add/update the passed --map entries.",
),
] = False,
) -> None:
"""Set the top-level workflow output projection without writing JSON Patch manually.
Default behavior replaces the full workflow output map. Pass all desired
--map entries in one command for a complete replacement. Use --merge only
when adding or updating entries across a later revision.
This edits WorkflowDraft.output (top-level workflow output). Use
wf draft set-output for step-level output bindings.
Repeat --map for multiple mappings:
--map state.markdown=markdown --map state.title=title
Run `wf draft validate <workspace_id>` after editing the projection.
"""
output_map = _parse_map_flags(mapping)
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
merge=merge,
),
)
)
@app.command("bind")
def bind_draft(
ctx: typer.Context,
+1
View File
@@ -50,6 +50,7 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
"wf.workflow.set_draft_route",
"wf.workflow.set_step_input_map",
"wf.workflow.set_step_output_map",
"wf.workflow.set_workflow_output_map",
"wf.workflow.create_minimal_draft_workspace",
"wf.workflow.create_artifact_from_workspace",
"wf.workflow.create_wrapper_from_workspace",
+15
View File
@@ -243,6 +243,21 @@ class SetStepOutputMapRequest(BaseModel):
)
class SetWorkflowOutputMapRequest(BaseModel):
"""Typed MCP request for replacing or merging workflow output projection."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
output_map: DraftPathMap
merge: bool = Field(
default=False,
description=(
"When false, replace the full workflow output map. When true, "
"preserve existing bindings and add/update only output_map entries."
),
)
class BindDraftRequest(BaseModel):
"""Typed MCP request for binding one draft step path with schema projection."""
+22
View File
@@ -37,6 +37,7 @@ from .models import (
SetDraftRouteRequest,
SetStepInputMapRequest,
SetStepOutputMapRequest,
SetWorkflowOutputMapRequest,
TraceRange,
ValidateDeploymentResult,
ValidateDraftWorkspaceRequest,
@@ -463,6 +464,27 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
)
)
@server.tool(
name="wf.workflow.set_workflow_output_map",
title="Set Workflow Output Map",
description=(
"Replace or merge the top-level workflow output projection. "
"Map graph source paths such as state.markdown to public output "
"fields such as markdown."
),
)
async def set_workflow_output_map(
request: SetWorkflowOutputMapRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.set_workflow_output_map(
workspace_id=request.workspace_id,
revision=request.revision,
output_map=request.output_map,
merge=request.merge,
)
)
@server.tool(
name="wf.workflow.bind",
title="Bind Draft",
+2
View File
@@ -42,6 +42,7 @@ from .models import (
SetDraftRouteParams,
SetStepInputMapParams,
SetStepOutputMapParams,
SetWorkflowOutputMapParams,
StartRunParams,
TraceRangeParams,
ValidateDeploymentParams,
@@ -88,6 +89,7 @@ __all__ = [
"SetDraftRouteParams",
"SetStepInputMapParams",
"SetStepOutputMapParams",
"SetWorkflowOutputMapParams",
"StartRunParams",
"TraceRangeParams",
"ValidateDeploymentParams",
@@ -141,6 +141,24 @@ class RpcDraftClientMixin:
},
)
async def set_workflow_output_map(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.set_workflow_output_map",
{
"workspace_id": workspace_id,
"revision": revision,
"output_map": output_map,
"merge": merge,
},
)
async def bind_draft(
self: RpcCaller,
*,
@@ -28,6 +28,7 @@ from ..models import (
SetDraftRouteParams,
SetStepInputMapParams,
SetStepOutputMapParams,
SetWorkflowOutputMapParams,
ValidateDraftParams,
ValidateDraftWorkspaceParams,
)
@@ -185,6 +186,23 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.set_workflow_output_map",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_set_workflow_output_map(
params: SetWorkflowOutputMapParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.set_workflow_output_map(
workspace_id=params.workspace_id,
revision=params.revision,
output_map=params.output_map,
merge=params.merge,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.bind",
errors=[WorkflowRpcError],
+7
View File
@@ -141,6 +141,13 @@ class SetStepOutputMapParams(RpcParamsModel):
merge: bool = False
class SetWorkflowOutputMapParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
output_map: dict[str, str]
merge: bool = False
class BindDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)