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
+4
View File
@@ -88,6 +88,10 @@ clear operator feedback before adding more architecture.
allowing agents to add missing target steps before final validation/save.
Implementation:
[`invalid intermediate draft authoring`](historical/superpowers/plans/2026-06-28-draft-invalid-intermediate-authoring.md).
- Completed: draft workspaces expose focused remove commands for routes, steps,
and step bindings so agents can recover from bad edits without raw JSON Patch.
Implementation:
[`draft remove commands`](historical/superpowers/plans/2026-06-28-draft-remove-commands.md).
- Keep status read-only; do not mutate registry, auth, config, or stores.
## Priority 2: Durable Run/Resume Hardening
@@ -0,0 +1,880 @@
# Draft Remove Commands Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add focused draft remove commands for routes, steps, and step bindings so agents can recover from bad draft edits without raw JSON Patch.
**Architecture:** Implement semantic remove helpers in `WorkflowDraftAuthoringApi`, delegate through `WorkflowApi`, and expose the same operations through RPC, MCP, and CLI. Removal persists structurally patchable edits and may return `status: "invalid"`; save/compile remain strict.
**Tech Stack:** Python 3.14, Typer, Pydantic, JSON-RPC transport, MCP workflow surface, pytest, Ruff, basedpyright.
---
## File Structure
- Modify `src/wf_api/draft_authoring.py`: add `remove_draft_route`, `remove_draft_step`, and `remove_draft_binding`.
- Modify `src/wf_api/service.py`: add facade delegates.
- Modify `src/wf_api/surface.py`: add protocol methods.
- Modify `src/wf_transport_rpc_http/models.py`: add params DTOs.
- Modify `src/wf_transport_rpc_http/methods/drafts.py`: add JSON-RPC methods.
- Modify `src/wf_transport_rpc_http/client/drafts.py`: add RPC client methods.
- Modify `src/wf_transport_rpc_http/__init__.py`: export DTOs if nearby draft DTOs are exported there.
- Modify `src/wf_mcp/workflow_surface/models.py`: add MCP request models.
- Modify `src/wf_mcp/workflow_surface/tools.py`: add MCP tools.
- Modify `src/wf_cli/commands/drafts.py`: add `remove-route`, `remove-step`, and `remove-binding`.
- Modify `tests/wf_api/test_drafts_service.py`: API behavior tests.
- Modify `tests/wf_transport_rpc_http/test_app.py`: JSON-RPC method tests.
- Modify `tests/wf_transport_rpc_http/test_client.py`: RPC client tests.
- Modify `tests/wf_mcp/server/test_config.py`: MCP tool registration assertions.
- Modify `tests/wf_cli/test_app.py`: CLI help assertions.
- Modify `tests/wf_cli/test_remote_target.py`: CLI/RPC routing smoke tests.
- Modify `docs/wf_cli.md`, `skills/wf-cli/SKILL.md`, and `skills/wf-workflow/references/draft-workspaces.md`: public guidance.
- Modify `docs/current_roadmap.md`: mark completion.
### Task 1: API Remove Helpers
**Files:**
- Modify: `src/wf_api/draft_authoring.py`
- Modify: `src/wf_api/service.py`
- Modify: `src/wf_api/surface.py`
- Test: `tests/wf_api/test_drafts_service.py`
- [ ] **Step 1: Write API tests**
Add these tests to `tests/wf_api/test_drafts_service.py` near the existing
branch/handle/add-step tests:
```python
@pytest.mark.asyncio
async def test_remove_draft_route_persists_invalid_workspace(tmp_path: Path) -> None:
api = _draft_api(FileWorkflowArtifactStore(tmp_path / "remove_route"))
await api.create_draft_workspace(
workspace_id="route_ws",
draft={
"name": "route_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await api.remove_draft_route(
workspace_id="route_ws",
revision=1,
step_id="echo",
outcome="ok",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
fetched = await api.get_draft_workspace(
workspace_id="route_ws",
include_draft=True,
)
assert fetched["draft"]["routes"]["echo"] == {}
```
```python
@pytest.mark.asyncio
async def test_remove_draft_step_removes_outgoing_routes_not_inbound_routes(
tmp_path: Path,
) -> None:
api = _draft_api(FileWorkflowArtifactStore(tmp_path / "remove_step"))
await api.create_draft_workspace(
workspace_id="step_ws",
draft={
"name": "step_ws",
"start": "first",
"steps": {
"first": {"use": "demo.personal.echo_tool", "input": [], "output": []},
"second": {"use": "demo.personal.echo_tool", "input": [], "output": []},
},
"routes": {
"first": {"ok": "second"},
"second": {"ok": "__end__"},
},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await api.remove_draft_step(
workspace_id="step_ws",
revision=1,
step_id="second",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
assert any(
item["code"] == "unknown_edge_destination"
for item in result["diagnostics"]
)
fetched = await api.get_draft_workspace(
workspace_id="step_ws",
include_draft=True,
)
assert "second" not in fetched["draft"]["steps"]
assert "second" not in fetched["draft"]["routes"]
assert fetched["draft"]["routes"]["first"]["ok"] == "second"
```
```python
@pytest.mark.asyncio
async def test_remove_draft_binding_removes_input_and_output_bindings(
tmp_path: Path,
) -> None:
api = _draft_api(FileWorkflowArtifactStore(tmp_path / "remove_binding"))
await api.create_draft_workspace(
workspace_id="binding_ws",
draft={
"name": "binding_ws",
"start": "echo",
"steps": {
"echo": {
"use": "demo.personal.echo_tool",
"input": [
{"path": "input.message", "target": "message"},
{"path": "input.extra", "target": "extra"},
],
"output": [
{"source": "echoed", "target": "state.echoed"},
{"source": "debug", "target": "state.debug"},
],
}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await api.remove_draft_binding(
workspace_id="binding_ws",
revision=1,
step_id="echo",
inputs=("message",),
outputs=("debug",),
)
assert result["revision"] == 2
fetched = await api.get_draft_workspace(
workspace_id="binding_ws",
include_draft=True,
)
assert fetched["draft"]["steps"]["echo"]["input"] == [
{"path": "input.extra", "target": "extra"}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
```
```python
@pytest.mark.asyncio
async def test_remove_missing_draft_element_is_noop(tmp_path: Path) -> None:
api = _draft_api(FileWorkflowArtifactStore(tmp_path / "remove_noop"))
await api.create_draft_workspace(
workspace_id="noop_ws",
draft={
"name": "noop_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await api.remove_draft_route(
workspace_id="noop_ws",
revision=1,
step_id="echo",
outcome="missing",
)
assert result["revision"] == 1
```
- [ ] **Step 2: Run tests and verify RED**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py::test_remove_draft_route_persists_invalid_workspace tests/wf_api/test_drafts_service.py::test_remove_draft_step_removes_outgoing_routes_not_inbound_routes tests/wf_api/test_drafts_service.py::test_remove_draft_binding_removes_input_and_output_bindings tests/wf_api/test_drafts_service.py::test_remove_missing_draft_element_is_noop -q
```
Expected: failures because the methods do not exist.
- [ ] **Step 3: Implement API helpers**
In `src/wf_api/draft_authoring.py`, add these methods to
`WorkflowDraftAuthoringApi` after `handle_draft`:
```python
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,
)
```
If `draft_step` is not imported in `draft_authoring.py`, import it from
`wf_api.draft_payloads`.
- [ ] **Step 4: Add service and protocol delegates**
In `src/wf_api/service.py`, add methods mirroring `branch_draft` and
`handle_draft`:
```python
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,
)
```
Import `Sequence` from `collections.abc` if it is not already available in
`service.py`. In `src/wf_api/surface.py`, add the same async method signatures
to `WorkflowDraftSurface`.
- [ ] **Step 5: Run API tests**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py::test_remove_draft_route_persists_invalid_workspace tests/wf_api/test_drafts_service.py::test_remove_draft_step_removes_outgoing_routes_not_inbound_routes tests/wf_api/test_drafts_service.py::test_remove_draft_binding_removes_input_and_output_bindings tests/wf_api/test_drafts_service.py::test_remove_missing_draft_element_is_noop -q
```
Expected: PASS.
- [ ] **Step 6: Commit**
```powershell
git add src/wf_api/draft_authoring.py src/wf_api/service.py src/wf_api/surface.py tests/wf_api/test_drafts_service.py
git commit -m "feat: add draft remove authoring helpers"
```
### Task 2: RPC And Client Surface
**Files:**
- Modify: `src/wf_transport_rpc_http/models.py`
- Modify: `src/wf_transport_rpc_http/methods/drafts.py`
- Modify: `src/wf_transport_rpc_http/client/drafts.py`
- Modify: `src/wf_transport_rpc_http/__init__.py`
- Test: `tests/wf_transport_rpc_http/test_app.py`
- Test: `tests/wf_transport_rpc_http/test_client.py`
- [ ] **Step 1: Add RPC DTOs**
In `src/wf_transport_rpc_http/models.py`, near other draft params, add:
```python
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)
```
Export these names from `src/wf_transport_rpc_http/__init__.py` if adjacent
draft DTOs are exported there.
- [ ] **Step 2: Register RPC methods**
In `src/wf_transport_rpc_http/methods/drafts.py`, add methods:
```python
workflow.draft_workspaces.remove_route
workflow.draft_workspaces.remove_step
workflow.draft_workspaces.remove_binding
```
Each method should call the corresponding `server.api` method and wrap
`ValueError`, `KeyError`, `LookupError`, and `FileNotFoundError` with
`raise_workflow_rpc_error`, matching `branch` and `handle`.
- [ ] **Step 3: Add RPC client methods**
In `src/wf_transport_rpc_http/client/drafts.py`, add:
```python
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: list[str] | None = None,
outputs: list[str] | None = None,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.remove_binding",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"inputs": inputs or [],
"outputs": outputs or [],
},
)
```
Each should call the method names from Step 2.
- [ ] **Step 4: Add transport tests**
In `tests/wf_transport_rpc_http/test_app.py`, extend the draft focused-edit
test or add a new test that calls `workflow.draft_workspaces.remove_route` and
asserts the route is absent after inspect.
In `tests/wf_transport_rpc_http/test_client.py`, add a client test that calls
`client.remove_draft_binding(...)` and asserts the request method/payload match:
```python
assert calls[-1]["method"] == "workflow.draft_workspaces.remove_binding"
assert calls[-1]["params"]["inputs"] == ["message"]
```
- [ ] **Step 5: Run transport tests**
Run:
```powershell
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q
```
Expected: PASS.
- [ ] **Step 6: Commit**
```powershell
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http
git commit -m "feat: expose draft remove helpers over rpc"
```
### Task 3: MCP And CLI Surface
**Files:**
- Modify: `src/wf_mcp/workflow_surface/models.py`
- Modify: `src/wf_mcp/workflow_surface/tools.py`
- Modify: `src/wf_cli/commands/drafts.py`
- Test: `tests/wf_mcp/server/test_config.py`
- Test: `tests/wf_cli/test_app.py`
- Test: `tests/wf_cli/test_remote_target.py`
- [ ] **Step 1: Add MCP request models and tools**
In `src/wf_mcp/workflow_surface/models.py`, add request models equivalent to
the RPC DTOs:
```python
class RemoveDraftRouteRequest(BaseModel):
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):
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):
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.",
)
```
In `src/wf_mcp/workflow_surface/tools.py`, register tools:
```text
wf.workflow.remove_draft_route
wf.workflow.remove_draft_step
wf.workflow.remove_draft_binding
```
Use descriptions that state removal may return `status: invalid` and should be
followed by validation.
- [ ] **Step 2: Add CLI commands**
In `src/wf_cli/commands/drafts.py`, add:
```python
@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 [],
),
)
)
```
CLI option shape:
```text
wf draft remove-route WORKSPACE --revision N --step STEP --outcome OUTCOME
wf draft remove-step WORKSPACE --revision N --step STEP
wf draft remove-binding WORKSPACE --revision N --step STEP --input LOCAL --output LOCAL
```
For `remove-binding`, accept repeated `--input` and repeated `--output`.
Raise `typer.BadParameter("pass at least one --input or --output")` when both
are empty.
- [ ] **Step 3: Add tests**
In `tests/wf_mcp/server/test_config.py`, assert the three MCP tool names are
registered.
In `tests/wf_cli/test_app.py`, assert `wf draft remove-binding --help` mentions:
```text
--input
--output
status: invalid
```
In `tests/wf_cli/test_remote_target.py`, add one RPC routing smoke that invokes:
```python
[
*base_args,
"draft",
"remove-route",
"route_ws",
"--revision",
"1",
"--step",
"call",
"--outcome",
"ok",
]
```
Assert exit code 0 and JSON `revision` is advanced when the route existed.
- [ ] **Step 4: Run MCP/CLI tests**
Run:
```powershell
uv run pytest tests/wf_mcp/server/test_config.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
```
Expected: PASS, except unrelated pre-existing broad test failures must be
reported with names and reasons.
- [ ] **Step 5: Commit**
```powershell
git add src/wf_mcp src/wf_cli tests/wf_mcp tests/wf_cli
git commit -m "feat: add draft remove cli and mcp tools"
```
### Task 4: Docs, Roadmap, And Final Verification
**Files:**
- Modify: `docs/wf_cli.md`
- Modify: `skills/wf-cli/SKILL.md`
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `docs/current_roadmap.md`
- Move: `docs/superpowers/plans/2026-06-28-draft-remove-commands.md`
- [ ] **Step 1: Update user docs**
In `docs/wf_cli.md`, under draft workspace commands, add:
````markdown
### Remove Draft Elements
Use remove commands to back out one bad route, step, or binding without writing
JSON Patch:
```bash
wf draft remove-route report_ws --revision 8 --step extract --outcome ok
wf draft remove-step report_ws --revision 9 --step render
wf draft remove-binding report_ws --revision 10 --step render --input title
wf draft remove-binding report_ws --revision 11 --step render --output markdown
```
Removal may leave the workspace `status: invalid`. That is normal for
intermediate authoring. Run `wf draft validate`, then repair routes or bindings
before saving or compiling.
````
In `skills/wf-cli/SKILL.md`, add a rule:
```markdown
- To undo a bad draft edit, prefer `wf draft remove-route`,
`wf draft remove-step`, or `wf draft remove-binding` over JSON Patch.
```
In `skills/wf-workflow/references/draft-workspaces.md`, add:
```markdown
Remove commands are for recovery. They do not delete schema fields and
`remove-step` does not remove inbound routes. Validate after removal and repair
the resulting diagnostics explicitly.
```
- [ ] **Step 2: Update roadmap**
Add a completed bullet under Priority 1:
```markdown
- Completed: draft workspaces expose focused remove commands for routes, steps,
and step bindings so agents can recover from bad edits without raw JSON Patch.
```
- [ ] **Step 3: Archive this plan**
Move this file to:
```text
docs/historical/superpowers/plans/2026-06-28-draft-remove-commands.md
```
- [ ] **Step 4: Run final verification**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_mcp/server/test_config.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py tests/docs -q
uv run ruff check src/wf_api/draft_authoring.py src/wf_transport_rpc_http src/wf_mcp/workflow_surface src/wf_cli/commands/drafts.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
uv run ruff format --check src/wf_api/draft_authoring.py src/wf_transport_rpc_http src/wf_mcp/workflow_surface src/wf_cli/commands/drafts.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
uv run basedpyright --level error src/wf_api/draft_authoring.py src/wf_transport_rpc_http src/wf_mcp/workflow_surface src/wf_cli/commands/drafts.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
```
Expected: all focused tests pass. Document any unrelated pre-existing failures
by test name and reason.
- [ ] **Step 5: Commit**
```powershell
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md docs/historical/superpowers/plans/2026-06-28-draft-remove-commands.md
git commit -m "docs: document draft remove commands"
```
## Self-Review
- Spec coverage: route removal, step removal, binding removal, no-op behavior,
invalid intermediate semantics, docs, and all public surfaces are covered.
- Placeholder scan: no TODO/TBD/fill-in placeholders remain.
- Type consistency: method names use `remove_draft_*`; CLI names use
`remove-*`; RPC method names use `workflow.draft_workspaces.remove_*`.
@@ -0,0 +1,133 @@
# Draft Remove Commands Design
## Status
Planned.
## Problem
Draft workspaces now support focused constructive edits: create a draft, add a
capability step, bind inputs/outputs, branch routes, handle outcomes, and
compile. The missing mirror operation is safe removal. In challenge runs, agents
recover from bad edits by switching to raw-plan import or by writing JSON Patch
directly, because there is no simple command for undoing one bad draft element.
The immediate recovery cases are:
- A wrong route target was added and should be removed before re-routing.
- A wrong capability step was added and should be removed.
- A wrong input or output binding was added and should be removed.
Generic `wf draft patch` can already express these changes, but it forces agents
to know JSON Pointer paths and the exact draft document shape. These focused
commands keep the public surface aligned with the draft authoring model.
## Scope
Add three semantic remove operations:
```text
wf draft remove-route <workspace_id> --revision N --step STEP --outcome OUTCOME
wf draft remove-step <workspace_id> --revision N --step STEP
wf draft remove-binding <workspace_id> --revision N --step STEP --input LOCAL
wf draft remove-binding <workspace_id> --revision N --step STEP --output LOCAL
```
`remove-binding` may accept repeated `--input` and `--output` flags in one call.
The names refer to local capability fields:
- `--input message` removes input bindings whose local target is `message`.
- `--output content` removes output bindings whose local source is `content`.
## Semantics
Remove commands use the same revision-checked draft workspace mutation path as
`branch`, `handle`, `bind`, and `add-step`.
If the requested element exists:
- the edit is persisted,
- the workspace revision increments,
- the returned status may be `valid` or `invalid`,
- diagnostics are returned when the removal leaves dangling control-flow or
schema/binding issues.
If the requested element does not exist:
- the operation is a no-op,
- the revision does not increment,
- the current workspace summary is returned.
This mirrors current no-op behavior in `branch`/`handle` and makes cleanup
commands idempotent enough for agents to retry safely.
## Step Removal Policy
`remove-step` removes:
- `steps[STEP]`
- `routes[STEP]`, if present
It does not remove inbound routes from other steps to `STEP`.
Reason: automatic inbound cleanup hides control-flow decisions. If removing a
step breaks the graph, validation should report `unknown_edge_destination` so
the agent can explicitly route the predecessor somewhere else with
`wf draft handle` or `wf draft branch`.
## Binding Removal Policy
`remove-binding` edits only the selected step's binding list:
- input mode removes entries from `/steps/{step}/input` where `target` equals
the provided local field.
- output mode removes entries from `/steps/{step}/output` where `source` equals
the provided local field.
It does not delete input/state/output schema fields. Schema deletion is a
separate problem because schema fields may still be referenced by other steps,
workflow outputs, or future edits. Validation diagnostics should surface unused
or broken paths; the first remove slice should not infer schema garbage
collection.
## Transport/API Shape
The API surface should add semantic methods on the draft authoring API:
```python
remove_draft_route(workspace_id, revision, step_id, outcome)
remove_draft_step(workspace_id, revision, step_id)
remove_draft_binding(workspace_id, revision, step_id, inputs, outputs)
```
Expose them through:
- `WorkflowApi` facade
- `WorkflowDraftSurface` protocol
- JSON-RPC methods under `workflow.draft_workspaces.*`
- RPC client mixin
- MCP workflow surface tools
- `wf draft` CLI commands
## Non-Goals
- Do not implement revision forking.
- Do not add a nested `wf draft step ...` namespace in this slice.
- Do not delete schema fields.
- Do not infer replacement routes.
- Do not make `remove-step` recursively delete dependent steps.
- Do not change strict `draft save` / `draft compile` boundaries.
## Acceptance Criteria
- `wf draft remove-route` removes an existing route and persists the resulting
draft, even if validation becomes invalid.
- `wf draft remove-step` removes the step and its outgoing route map, leaves
inbound routes untouched, and returns diagnostics if the graph now points at a
missing step.
- `wf draft remove-binding --input` removes matching input bindings.
- `wf draft remove-binding --output` removes matching output bindings.
- Missing elements are no-op operations that do not advance revision.
- All commands are exposed over API, RPC, MCP, and CLI.
- Docs and skills explain that remove commands may return `status: invalid` and
should be followed by `wf draft validate`.
+16
View File
@@ -377,6 +377,22 @@ Use `wf draft handle` to route multiple source step outcomes to a common target:
wf draft handle concat_ws --revision 7 --to fail --branch lookup:error --branch transform:error
```
### Remove Draft Elements
Use remove commands to back out one bad route, step, or binding without writing
JSON Patch:
```bash
wf draft remove-route report_ws --revision 8 --step extract --outcome ok
wf draft remove-step report_ws --revision 9 --step render
wf draft remove-binding report_ws --revision 10 --step render --input title
wf draft remove-binding report_ws --revision 11 --step render --output markdown
```
Removal may leave the workspace `status: invalid`. That is normal for
intermediate authoring. Run `wf draft validate`, then repair routes or bindings
before saving or compiling.
### Compile A Draft Workspace
Use `wf draft compile` to print the compiled raw plan without mutating or saving
+3
View File
@@ -95,6 +95,9 @@ each declared outcome; extra outcome names are rejected.
`wf draft compile` prints the raw plan JSON directly on success. Do not expect a
top-level `compiled_plan` key from the CLI output.
- To undo a bad draft edit, prefer `wf draft remove-route`,
`wf draft remove-step`, or `wf draft remove-binding` over JSON Patch.
## Rules
- Use explicit `--config <path>` for examples, challenge workspaces, and
@@ -165,6 +165,10 @@ wf draft validate <workspace_id>
Validation repair hints are product guidance. If a diagnostic suggests
`wf draft bind`, use it before hand-editing schemas or step bindings.
Remove commands are for recovery. They do not delete schema fields and
`remove-step` does not remove inbound routes. Validate after removal and repair
the resulting diagnostics explicitly.
Use JSON Patch for structural edits the helpers do not cover.
For larger patches, write a JSON Patch array to a file and pass it with
+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)
+164
View File
@@ -954,6 +954,170 @@ async def test_add_step_from_capability_rejects_unknown_routes_for_multi_outcome
)
# -- Draft remove helpers --
@pytest.mark.asyncio
async def test_remove_draft_route_persists_invalid_workspace(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_route")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="route_ws",
draft={
"name": "route_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_route(
workspace_id="route_ws",
revision=1,
step_id="echo",
outcome="ok",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
fetched = await api.get_draft_workspace(
workspace_id="route_ws",
include_draft=True,
)
assert fetched["draft"]["routes"]["echo"] == {}
@pytest.mark.asyncio
async def test_remove_draft_step_removes_outgoing_routes_not_inbound_routes(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_step")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="step_ws",
draft={
"name": "step_ws",
"start": "first",
"steps": {
"first": {"use": "demo.personal.echo_tool", "input": [], "output": []},
"second": {"use": "demo.personal.echo_tool", "input": [], "output": []},
},
"routes": {
"first": {"ok": "second"},
"second": {"ok": "__end__"},
},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_step(
workspace_id="step_ws",
revision=1,
step_id="second",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
assert any(
item["code"] == "unknown_edge_destination" for item in result["diagnostics"]
)
fetched = await api.get_draft_workspace(
workspace_id="step_ws",
include_draft=True,
)
assert "second" not in fetched["draft"]["steps"]
assert "second" not in fetched["draft"]["routes"]
assert fetched["draft"]["routes"]["first"]["ok"] == "second"
@pytest.mark.asyncio
async def test_remove_draft_binding_removes_input_and_output_bindings(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_binding")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="binding_ws",
draft={
"name": "binding_ws",
"start": "echo",
"steps": {
"echo": {
"use": "demo.personal.echo_tool",
"input": [
{"path": "input.message", "target": "message"},
{"path": "input.extra", "target": "extra"},
],
"output": [
{"source": "echoed", "target": "state.echoed"},
{"source": "debug", "target": "state.debug"},
],
}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_binding(
workspace_id="binding_ws",
revision=1,
step_id="echo",
inputs=("message",),
outputs=("debug",),
)
assert result["revision"] == 2
fetched = await api.get_draft_workspace(
workspace_id="binding_ws",
include_draft=True,
)
assert fetched["draft"]["steps"]["echo"]["input"] == [
{"path": "input.extra", "target": "extra"}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
@pytest.mark.asyncio
async def test_remove_missing_draft_element_is_noop(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_noop")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft={
"name": "noop_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_route(
workspace_id="noop_ws",
revision=1,
step_id="echo",
outcome="missing",
)
assert result["revision"] == 1
@pytest.mark.asyncio
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
+16
View File
@@ -181,6 +181,22 @@ def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
assert "add-step-from-capability" not in result.output
def test_wf_draft_remove_commands_help_mentions_options() -> None:
route_result = runner.invoke(app, ["draft", "remove-route", "--help"])
step_result = runner.invoke(app, ["draft", "remove-step", "--help"])
binding_result = runner.invoke(app, ["draft", "remove-binding", "--help"])
assert route_result.exit_code == 0
assert "--step" in route_result.output
assert "--outcome" in route_result.output
assert step_result.exit_code == 0
assert "--step" in step_result.output
assert binding_result.exit_code == 0
assert "--input" in binding_result.output
assert "--output" in binding_result.output
assert "status: invalid" in binding_result.output
def test_wf_draft_route_flags_reject_duplicate_outcomes() -> None:
add_result = runner.invoke(
app,
+44
View File
@@ -1143,6 +1143,50 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
]
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"remove_route_ws",
"--capability",
"wf.std.constant",
"--name",
"remove_route",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"remove-route",
"remove_route_ws",
"--revision",
"1",
"--step",
"call",
"--outcome",
"ok",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["revision"] == 2
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
+3
View File
@@ -58,6 +58,9 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.bind" in names
assert "wf.workflow.add_step_from_capability" in names
assert "wf.workflow.remove_draft_route" in names
assert "wf.workflow.remove_draft_step" in names
assert "wf.workflow.remove_draft_binding" in names
assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_draft_workspace_from_capability" in names
assert "wf.workflow.create_artifact_from_workspace" in names
+39
View File
@@ -775,6 +775,45 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
)
async def test_rpc_draft_workspace_remove_route(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_from_capability",
{
"workspace_id": "remove_route_ws",
"capability_name": "wf.std.constant",
"name": "remove_route_test",
"output_map": {"value": "state.result"},
},
)
assert created["result"]["workspace_id"] == "remove_route_ws"
removed = await _rpc(
client,
"workflow.draft_workspaces.remove_route",
{
"workspace_id": "remove_route_ws",
"revision": 1,
"step_id": "call",
"outcome": "ok",
},
)
assert removed["result"]["revision"] == 2
assert removed["result"]["status"] == "invalid"
inspected = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "remove_route_ws", "include_draft": True},
)
assert inspected["result"]["draft"]["routes"]["call"] == {}
async def test_rpc_draft_workspace_add_step_from_capability(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
import httpx
from wf_api.models import RawWorkflowPlan, TraceRange
@@ -7,6 +9,7 @@ from wf_api.surface import WorkflowDraftSurface
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
from wf_transport_rpc_http.client.drafts import RpcDraftClientMixin
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
@@ -518,6 +521,43 @@ async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None
assert state_bound["revision"] == 8
async def test_rpc_client_draft_remove_methods(tmp_path) -> None:
calls: list[dict[str, Any]] = []
class Client(RpcDraftClientMixin):
async def _call(self, method: str, params: dict[str, object]):
calls.append({"method": method, "params": params})
return {"revision": 2}
client = Client()
route_result = await client.remove_draft_route(
workspace_id="ws",
revision=1,
step_id="call",
outcome="ok",
)
step_result = await client.remove_draft_step(
workspace_id="ws",
revision=1,
step_id="call",
)
binding_result = await client.remove_draft_binding(
workspace_id="ws",
revision=1,
step_id="echo",
inputs=["message"],
outputs=["debug"],
)
assert route_result == {"revision": 2}
assert step_result == {"revision": 2}
assert binding_result == {"revision": 2}
assert calls[0]["method"] == "workflow.draft_workspaces.remove_route"
assert calls[1]["method"] == "workflow.draft_workspaces.remove_step"
assert calls[2]["method"] == "workflow.draft_workspaces.remove_binding"
assert calls[2]["params"]["inputs"] == ["message"]
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)