feat: add focused draft remove commands
This commit is contained in:
@@ -88,6 +88,10 @@ clear operator feedback before adding more architecture.
|
|||||||
allowing agents to add missing target steps before final validation/save.
|
allowing agents to add missing target steps before final validation/save.
|
||||||
Implementation:
|
Implementation:
|
||||||
[`invalid intermediate draft authoring`](historical/superpowers/plans/2026-06-28-draft-invalid-intermediate-authoring.md).
|
[`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.
|
- Keep status read-only; do not mutate registry, auth, config, or stores.
|
||||||
|
|
||||||
## Priority 2: Durable Run/Resume Hardening
|
## 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`.
|
||||||
@@ -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
|
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
|
### Compile A Draft Workspace
|
||||||
|
|
||||||
Use `wf draft compile` to print the compiled raw plan without mutating or saving
|
Use `wf draft compile` to print the compiled raw plan without mutating or saving
|
||||||
|
|||||||
@@ -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
|
`wf draft compile` prints the raw plan JSON directly on success. Do not expect a
|
||||||
top-level `compiled_plan` key from the CLI output.
|
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
|
## Rules
|
||||||
|
|
||||||
- Use explicit `--config <path>` for examples, challenge workspaces, and
|
- 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
|
Validation repair hints are product guidance. If a diagnostic suggests
|
||||||
`wf draft bind`, use it before hand-editing schemas or step bindings.
|
`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.
|
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
|
For larger patches, write a JSON Patch array to a file and pass it with
|
||||||
|
|||||||
@@ -482,6 +482,143 @@ class WorkflowDraftAuthoringApi:
|
|||||||
patch=patch,
|
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)
|
@dataclass(frozen=True)
|
||||||
class DraftOutcomeRef:
|
class DraftOutcomeRef:
|
||||||
|
|||||||
@@ -478,6 +478,51 @@ class WorkflowApi:
|
|||||||
title=title,
|
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(
|
async def create_draft_workspace_from_capability(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -156,6 +156,33 @@ class WorkflowDraftSurface(Protocol):
|
|||||||
target: str,
|
target: str,
|
||||||
) -> dict[str, Any]: ...
|
) -> 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(
|
async def validate_draft_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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")
|
@app.command("validate")
|
||||||
def validate_draft(
|
def validate_draft(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -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):
|
class BranchDraftRequest(BaseModel):
|
||||||
"""Typed MCP request for branching routes on a draft step."""
|
"""Typed MCP request for branching routes on a draft step."""
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from .models import (
|
|||||||
DraftWorkspaceResult,
|
DraftWorkspaceResult,
|
||||||
HandleDraftRequest,
|
HandleDraftRequest,
|
||||||
PatchDraftWorkspaceRequest,
|
PatchDraftWorkspaceRequest,
|
||||||
|
RemoveDraftBindingRequest,
|
||||||
|
RemoveDraftRouteRequest,
|
||||||
|
RemoveDraftStepRequest,
|
||||||
RunDeploymentResult,
|
RunDeploymentResult,
|
||||||
SetDraftNameRequest,
|
SetDraftNameRequest,
|
||||||
SetDraftRouteRequest,
|
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(
|
@server.tool(
|
||||||
name="wf.workflow.create_minimal_draft_workspace",
|
name="wf.workflow.create_minimal_draft_workspace",
|
||||||
title="Create Minimal Draft Workspace",
|
title="Create Minimal Draft Workspace",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ from .models import (
|
|||||||
PatchDraftParams,
|
PatchDraftParams,
|
||||||
PatchDraftWorkspaceParams,
|
PatchDraftWorkspaceParams,
|
||||||
ReadRunTraceParams,
|
ReadRunTraceParams,
|
||||||
|
RemoveDraftBindingParams,
|
||||||
|
RemoveDraftRouteParams,
|
||||||
|
RemoveDraftStepParams,
|
||||||
ResumeRunParams,
|
ResumeRunParams,
|
||||||
SaveArtifactParams,
|
SaveArtifactParams,
|
||||||
SaveDeploymentParams,
|
SaveDeploymentParams,
|
||||||
@@ -75,6 +78,9 @@ __all__ = [
|
|||||||
"PatchDraftParams",
|
"PatchDraftParams",
|
||||||
"PatchDraftWorkspaceParams",
|
"PatchDraftWorkspaceParams",
|
||||||
"ReadRunTraceParams",
|
"ReadRunTraceParams",
|
||||||
|
"RemoveDraftBindingParams",
|
||||||
|
"RemoveDraftRouteParams",
|
||||||
|
"RemoveDraftStepParams",
|
||||||
"ResumeRunParams",
|
"ResumeRunParams",
|
||||||
"SaveArtifactParams",
|
"SaveArtifactParams",
|
||||||
"SaveDeploymentParams",
|
"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(
|
async def validate_draft_workspace(
|
||||||
self: RpcCaller,
|
self: RpcCaller,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from ..models import (
|
|||||||
ListDraftWorkspacesParams,
|
ListDraftWorkspacesParams,
|
||||||
PatchDraftParams,
|
PatchDraftParams,
|
||||||
PatchDraftWorkspaceParams,
|
PatchDraftWorkspaceParams,
|
||||||
|
RemoveDraftBindingParams,
|
||||||
|
RemoveDraftRouteParams,
|
||||||
|
RemoveDraftStepParams,
|
||||||
SetDraftNameParams,
|
SetDraftNameParams,
|
||||||
SetDraftRouteParams,
|
SetDraftRouteParams,
|
||||||
SetStepInputMapParams,
|
SetStepInputMapParams,
|
||||||
@@ -296,6 +299,54 @@ def register_methods(
|
|||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
raise_workflow_rpc_error(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(
|
@entrypoint.method(
|
||||||
name="workflow.draft_workspaces.create_artifact", errors=[WorkflowRpcError]
|
name="workflow.draft_workspaces.create_artifact", errors=[WorkflowRpcError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -180,6 +180,27 @@ class HandleDraftParams(RpcParamsModel):
|
|||||||
target: str = Field(min_length=1)
|
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):
|
class ValidateDraftWorkspaceParams(RpcParamsModel):
|
||||||
workspace_id: str = Field(min_length=1)
|
workspace_id: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@pytest.mark.asyncio
|
||||||
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
|
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
|
||||||
|
|||||||
@@ -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
|
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:
|
def test_wf_draft_route_flags_reject_duplicate_outcomes() -> None:
|
||||||
add_result = runner.invoke(
|
add_result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -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:
|
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
|
|||||||
@@ -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.set_step_output_map" in names
|
||||||
assert "wf.workflow.bind" in names
|
assert "wf.workflow.bind" in names
|
||||||
assert "wf.workflow.add_step_from_capability" 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_minimal_draft_workspace" in names
|
||||||
assert "wf.workflow.create_draft_workspace_from_capability" in names
|
assert "wf.workflow.create_draft_workspace_from_capability" in names
|
||||||
assert "wf.workflow.create_artifact_from_workspace" in names
|
assert "wf.workflow.create_artifact_from_workspace" in names
|
||||||
|
|||||||
@@ -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:
|
async def test_rpc_draft_workspace_add_step_from_capability(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wf_api.models import RawWorkflowPlan, TraceRange
|
from wf_api.models import RawWorkflowPlan, TraceRange
|
||||||
@@ -7,6 +9,7 @@ from wf_api.surface import WorkflowDraftSurface
|
|||||||
from wf_core import END
|
from wf_core import END
|
||||||
from wf_server import build_local_static_workflow_server
|
from wf_server import build_local_static_workflow_server
|
||||||
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
|
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
|
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
|
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:
|
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
|
|||||||
Reference in New Issue
Block a user