881 lines
29 KiB
Markdown
881 lines
29 KiB
Markdown
# 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_*`.
|