feat: add focused draft remove commands

This commit is contained in:
lda
2026-06-28 18:15:54 +07:00 Verified
parent 10d6314882
commit 270e646bbb
22 changed files with 1870 additions and 0 deletions
+137
View File
@@ -482,6 +482,143 @@ class WorkflowDraftAuthoringApi:
patch=patch,
)
async def remove_draft_route(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
outcome: str,
) -> dict[str, Any]:
"""Remove one route; missing routes are revision-checked no-ops."""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
draft_routes = workspace.draft.get("routes", {})
if not isinstance(draft_routes, dict):
raise ValueError("draft routes must be an object")
step_routes = draft_routes.get(step_id, {})
if not isinstance(step_routes, dict):
raise ValueError(f"routes for step {step_id!r} must be an object")
if outcome not in step_routes:
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "remove",
"path": (
f"/routes/{escape_json_pointer(step_id)}/"
f"{escape_json_pointer(outcome)}"
),
}
],
)
async def remove_draft_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
) -> dict[str, Any]:
"""Remove a step and its own route map; inbound routes are left explicit."""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
steps = workspace.draft.get("steps", {})
if not isinstance(steps, dict):
raise ValueError("draft steps must be an object")
if step_id not in steps:
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
patch = [
{
"op": "remove",
"path": f"/steps/{escape_json_pointer(step_id)}",
}
]
routes = workspace.draft.get("routes", {})
if isinstance(routes, dict) and step_id in routes:
patch.append(
{
"op": "remove",
"path": f"/routes/{escape_json_pointer(step_id)}",
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
async def remove_draft_binding(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> dict[str, Any]:
"""Remove selected local input/output bindings from one draft step."""
if not inputs and not outputs:
raise ValueError("pass at least one input or output binding to remove")
workspace = self.drafts._draft_store().get_workspace(workspace_id)
step = draft_step(workspace.draft, step_id)
current_inputs = step.get("input", [])
current_outputs = step.get("output", [])
if not isinstance(current_inputs, list):
raise ValueError(f"input bindings for step {step_id!r} must be a list")
if not isinstance(current_outputs, list):
raise ValueError(f"output bindings for step {step_id!r} must be a list")
input_targets = set(inputs)
output_sources = set(outputs)
next_inputs = [
item for item in current_inputs if item.get("target") not in input_targets
]
next_outputs = [
item for item in current_outputs if item.get("source") not in output_sources
]
if next_inputs == current_inputs and next_outputs == current_outputs:
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
patch: list[dict[str, Any]] = []
if next_inputs != current_inputs:
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": next_inputs,
}
)
if next_outputs != current_outputs:
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": next_outputs,
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
@dataclass(frozen=True)
class DraftOutcomeRef:
+45
View File
@@ -478,6 +478,51 @@ class WorkflowApi:
title=title,
)
async def remove_draft_route(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
outcome: str,
) -> dict[str, Any]:
return await self.draft_authoring.remove_draft_route(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
outcome=outcome,
)
async def remove_draft_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
) -> dict[str, Any]:
return await self.draft_authoring.remove_draft_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
)
async def remove_draft_binding(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> dict[str, Any]:
return await self.draft_authoring.remove_draft_binding(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
inputs=inputs,
outputs=outputs,
)
async def create_draft_workspace_from_capability(
self,
*,
+27
View File
@@ -156,6 +156,33 @@ class WorkflowDraftSurface(Protocol):
target: str,
) -> dict[str, Any]: ...
async def remove_draft_route(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
outcome: str,
) -> dict[str, Any]: ...
async def remove_draft_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
) -> dict[str, Any]: ...
async def remove_draft_binding(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> dict[str, Any]: ...
async def validate_draft_workspace(
self,
*,
+86
View File
@@ -489,6 +489,92 @@ def handle_draft(
)
@app.command("remove-route")
def remove_draft_route(
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: Annotated[str, typer.Option("--step", help="Draft step id.")],
outcome: Annotated[str, typer.Option("--outcome", help="Outcome route to remove.")],
) -> None:
"""Remove one route from a draft step."""
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.remove_draft_route(
workspace_id=workspace_id,
revision=revision,
step_id=step,
outcome=outcome,
),
)
)
@app.command("remove-step")
def remove_draft_step(
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: Annotated[str, typer.Option("--step", help="Draft step id.")],
) -> None:
"""Remove one step and its outgoing draft route map."""
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.remove_draft_step(
workspace_id=workspace_id,
revision=revision,
step_id=step,
),
)
)
@app.command("remove-binding")
def remove_draft_binding(
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: Annotated[str, typer.Option("--step", help="Draft step id.")],
input_name: Annotated[
list[str] | None,
typer.Option("--input", help="Local input target to remove. Repeatable."),
] = None,
output_name: Annotated[
list[str] | None,
typer.Option("--output", help="Local output source to remove. Repeatable."),
] = None,
) -> None:
"""Remove selected input/output bindings from one draft step.
Removal may return status: invalid. Run `wf draft validate` after cleanup.
"""
if not input_name and not output_name:
raise typer.BadParameter("pass at least one --input or --output")
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.remove_draft_binding(
workspace_id=workspace_id,
revision=revision,
step_id=step,
inputs=input_name or [],
outputs=output_name or [],
),
)
)
@app.command("validate")
def validate_draft(
ctx: typer.Context,
+33
View File
@@ -286,6 +286,39 @@ class AddStepFromCapabilityRequest(BaseModel):
)
class RemoveDraftRouteRequest(BaseModel):
"""Typed MCP request for removing one route from a draft step."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Draft step id whose route should be removed.")
outcome: str = Field(description="Outcome label to remove from the step route map.")
class RemoveDraftStepRequest(BaseModel):
"""Typed MCP request for removing a draft step and its outgoing routes."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Draft step id to remove.")
class RemoveDraftBindingRequest(BaseModel):
"""Typed MCP request for removing input/output bindings from a draft step."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Draft step id whose bindings should be removed.")
inputs: list[str] = Field(
default_factory=list,
description="Local input target names to remove.",
)
outputs: list[str] = Field(
default_factory=list,
description="Local output source names to remove.",
)
class BranchDraftRequest(BaseModel):
"""Typed MCP request for branching routes on a draft step."""
+64
View File
@@ -29,6 +29,9 @@ from .models import (
DraftWorkspaceResult,
HandleDraftRequest,
PatchDraftWorkspaceRequest,
RemoveDraftBindingRequest,
RemoveDraftRouteRequest,
RemoveDraftStepRequest,
RunDeploymentResult,
SetDraftNameRequest,
SetDraftRouteRequest,
@@ -543,6 +546,67 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
)
)
@server.tool(
name="wf.workflow.remove_draft_route",
title="Remove Draft Route",
description=(
"Remove one route from a draft step. Missing routes are safe no-ops. "
"Removal may return status: invalid; validate after cleanup."
),
)
async def remove_draft_route(
request: RemoveDraftRouteRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.remove_draft_route(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
outcome=request.outcome,
)
)
@server.tool(
name="wf.workflow.remove_draft_step",
title="Remove Draft Step",
description=(
"Remove a step and its outgoing route map from a draft workspace. "
"Inbound routes remain explicit. Use remove_draft_route to clean them up."
),
)
async def remove_draft_step(
request: RemoveDraftStepRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.remove_draft_step(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
)
)
@server.tool(
name="wf.workflow.remove_draft_binding",
title="Remove Draft Binding",
description=(
"Remove selected input/output bindings from one draft step. "
"Pass inputs and/or outputs lists to identify bindings by target/source name. "
"Removal may return status: invalid; validate after cleanup."
),
)
async def remove_draft_binding(
request: RemoveDraftBindingRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.remove_draft_binding(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
inputs=request.inputs,
outputs=request.outputs,
)
)
@server.tool(
name="wf.workflow.create_minimal_draft_workspace",
title="Create Minimal Draft Workspace",
+6
View File
@@ -32,6 +32,9 @@ from .models import (
PatchDraftParams,
PatchDraftWorkspaceParams,
ReadRunTraceParams,
RemoveDraftBindingParams,
RemoveDraftRouteParams,
RemoveDraftStepParams,
ResumeRunParams,
SaveArtifactParams,
SaveDeploymentParams,
@@ -75,6 +78,9 @@ __all__ = [
"PatchDraftParams",
"PatchDraftWorkspaceParams",
"ReadRunTraceParams",
"RemoveDraftBindingParams",
"RemoveDraftRouteParams",
"RemoveDraftStepParams",
"ResumeRunParams",
"SaveArtifactParams",
"SaveDeploymentParams",
@@ -225,6 +225,60 @@ class RpcDraftClientMixin:
},
)
async def remove_draft_route(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
outcome: str,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.remove_route",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"outcome": outcome,
},
)
async def remove_draft_step(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.remove_step",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
},
)
async def remove_draft_binding(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.remove_binding",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"inputs": list(inputs),
"outputs": list(outputs),
},
)
async def validate_draft_workspace(
self: RpcCaller,
*,
@@ -21,6 +21,9 @@ from ..models import (
ListDraftWorkspacesParams,
PatchDraftParams,
PatchDraftWorkspaceParams,
RemoveDraftBindingParams,
RemoveDraftRouteParams,
RemoveDraftStepParams,
SetDraftNameParams,
SetDraftRouteParams,
SetStepInputMapParams,
@@ -296,6 +299,54 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.remove_route", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_remove_route(
params: RemoveDraftRouteParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.remove_draft_route(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
outcome=params.outcome,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.remove_step", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_remove_step(
params: RemoveDraftStepParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.remove_draft_step(
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.remove_binding", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_remove_binding(
params: RemoveDraftBindingParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.remove_draft_binding(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
inputs=params.inputs,
outputs=params.outputs,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.create_artifact", errors=[WorkflowRpcError]
)
+21
View File
@@ -180,6 +180,27 @@ class HandleDraftParams(RpcParamsModel):
target: str = Field(min_length=1)
class RemoveDraftRouteParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
outcome: str = Field(min_length=1)
class RemoveDraftStepParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
class RemoveDraftBindingParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
inputs: list[str] = Field(default_factory=list)
outputs: list[str] = Field(default_factory=list)
class ValidateDraftWorkspaceParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)