1024 lines
34 KiB
Markdown
1024 lines
34 KiB
Markdown
# Draft Add Step From Capability 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 a focused draft helper that inserts one capability-backed step, optional route wiring, input bindings, and output-to-state schema/binding projection in one revision-checked operation.
|
||
|
||
**Architecture:** Build one API operation on `WorkflowDraftApi` that composes existing draft primitives without calling multiple revision-mutating helpers. The operation constructs one JSON Patch against the current draft, reuses existing binding payload helpers, and reuses `project_output_property_to_state_schema()` for `$defs` / `definitions` preservation. Expose the operation through WorkflowApi, JSON-RPC, RPC client, MCP workflow tool, CLI, docs, and skills.
|
||
|
||
**Tech Stack:** Python 3.14, Pydantic RPC DTOs, Typer CLI, existing `wf_api` draft service, existing JSON Patch draft workspace storage, pytest, ruff, basedpyright.
|
||
|
||
---
|
||
|
||
## Scope
|
||
|
||
This slice is explicit, not magic.
|
||
|
||
The helper **does**:
|
||
|
||
- Add `steps.<step_id>` with `{"use": capability_name, "input": [...], "output": [...]}`.
|
||
- Add `routes.<step_id>.<outcome> = target` for the new step.
|
||
- Optionally add `routes.<from_step>.<from_outcome> = step_id` to splice the step after an existing step.
|
||
- Accept repeated input bindings as `SOURCE=LOCAL_TARGET`, same direction as `wf draft set-input`.
|
||
- Accept repeated output-to-state bindings as `LOCAL_OUTPUT=STATE_TARGET`, and for each one:
|
||
- copy that output property schema into `state_schema.properties.<field>`,
|
||
- preserve local `$defs` / `definitions`,
|
||
- add the step output binding `local.<output> -> state.<field>`.
|
||
- Return the normal updated workspace payload and diagnostics.
|
||
|
||
The helper **does not**:
|
||
|
||
- Guess missing inputs or outputs.
|
||
- Auto-wire top-level workflow output.
|
||
- Support non-capability/control steps.
|
||
- Hide validation. Agents should still run `wf draft validate <workspace_id>`.
|
||
|
||
CLI target shape:
|
||
|
||
```bash
|
||
wf draft add-step-from-capability <workspace_id> \
|
||
--revision <n> \
|
||
--step wait \
|
||
--capability local.browser_click.wait_for_click \
|
||
--from-step open \
|
||
--from-outcome ok \
|
||
--outcome ok \
|
||
--to collect \
|
||
--input state.session=session \
|
||
--bind-output after=state.after
|
||
```
|
||
|
||
Terminology:
|
||
|
||
- `--from-step/--from-outcome`: optional incoming edge from an existing step to the new step.
|
||
- `--outcome/--to`: outgoing edge from the new step to another step or `__end__`.
|
||
- `--input SOURCE=LOCAL`: graph source to node-local input target.
|
||
- `--bind-output LOCAL=STATE`: node-local output field to state path, with schema projection.
|
||
|
||
---
|
||
|
||
## Files
|
||
|
||
- Modify: `src/wf_api/drafts.py`
|
||
- Modify: `src/wf_api/service.py`
|
||
- Modify: `src/wf_api/surface.py`
|
||
- 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`
|
||
- 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_api/test_drafts_service.py`
|
||
- Test: `tests/wf_transport_rpc_http/test_app.py`
|
||
- Test: `tests/wf_transport_rpc_http/test_client.py`
|
||
- Test: `tests/wf_mcp/server/test_config.py`
|
||
- Test: `tests/wf_cli/test_app.py`
|
||
- Test: `tests/wf_cli/test_remote_target.py`
|
||
- Modify docs/skills: `docs/wf_cli.md`, `skills/wf-cli/SKILL.md`, `skills/wf-workflow/references/draft-workspaces.md`, `skills/wf-workflow/references/workflow-lifecycle.md`, `docs/current_roadmap.md`
|
||
|
||
---
|
||
|
||
## Task 1: API Operation
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/drafts.py`
|
||
- Test: `tests/wf_api/test_drafts_service.py`
|
||
|
||
- [ ] **Step 1: Write failing API test for adding a fully wired step**
|
||
|
||
Add this test near the existing draft helper tests in `tests/wf_api/test_drafts_service.py`:
|
||
|
||
```python
|
||
@pytest.mark.asyncio
|
||
async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
|
||
tmp_path: Path,
|
||
) -> None:
|
||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step")
|
||
api, service = _draft_api(artifact_store, register_echo=True)
|
||
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
||
await api.create_draft_workspace(
|
||
workspace_id="echo_ws",
|
||
draft=_echo_draft(),
|
||
)
|
||
|
||
result = await api.add_step_from_capability(
|
||
workspace_id="echo_ws",
|
||
revision=1,
|
||
step_id="snap",
|
||
capability_name="demo.personal.snapshot_tool",
|
||
route_from_step="echo",
|
||
route_from_outcome="ok",
|
||
route_outcome="ok",
|
||
route_to="__end__",
|
||
input_map={},
|
||
bind_outputs={"after": "state.after"},
|
||
)
|
||
|
||
assert result["revision"] == 2
|
||
assert result["status"] == "valid"
|
||
draft = result["draft"]
|
||
assert draft["steps"]["snap"]["use"] == "demo.personal.snapshot_tool"
|
||
assert draft["routes"]["echo"]["ok"] == "snap"
|
||
assert draft["routes"]["snap"]["ok"] == "__end__"
|
||
assert draft["steps"]["snap"]["output"] == [
|
||
{
|
||
"source": {"root": "local", "parts": ["after"]},
|
||
"target": {"root": "state", "parts": ["after"]},
|
||
}
|
||
]
|
||
assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot"
|
||
assert draft["state_schema"]["$defs"]["_Snapshot"]["properties"]["clicked"] == {
|
||
"title": "Clicked",
|
||
"type": "boolean",
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing API test for duplicate step rejection**
|
||
|
||
Add:
|
||
|
||
```python
|
||
@pytest.mark.asyncio
|
||
async def test_add_step_from_capability_rejects_existing_step_id(
|
||
tmp_path: Path,
|
||
) -> None:
|
||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate")
|
||
api, _service = _draft_api(artifact_store, register_echo=True)
|
||
await api.create_draft_workspace(
|
||
workspace_id="echo_ws",
|
||
draft=_echo_draft(),
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="draft step 'echo' already exists"):
|
||
await api.add_step_from_capability(
|
||
workspace_id="echo_ws",
|
||
revision=1,
|
||
step_id="echo",
|
||
capability_name="demo.personal.echo_tool",
|
||
route_from_step=None,
|
||
route_from_outcome="ok",
|
||
route_outcome="ok",
|
||
route_to="__end__",
|
||
input_map={},
|
||
bind_outputs={},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Run failing API tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_api/test_drafts_service.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: fail because `WorkflowDraftApi.add_step_from_capability` does not exist.
|
||
|
||
- [ ] **Step 4: Implement API helper**
|
||
|
||
In `src/wf_api/drafts.py`, add this method to `WorkflowDraftApi` after `bind_output_to_state`:
|
||
|
||
```python
|
||
async def add_step_from_capability(
|
||
self,
|
||
*,
|
||
workspace_id: str,
|
||
revision: int,
|
||
step_id: str,
|
||
capability_name: str,
|
||
route_from_step: str | None = None,
|
||
route_from_outcome: str = DEFAULT_OK_OUTCOME,
|
||
route_outcome: str = DEFAULT_OK_OUTCOME,
|
||
route_to: str = "__end__",
|
||
input_map: dict[str, str] | None = None,
|
||
bind_outputs: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Add one capability step plus explicit route/map/schema wiring.
|
||
|
||
This is a composed authoring helper for agents. It edits the draft in
|
||
one revision so callers do not have to interleave add-step, route,
|
||
input-map, state-schema, and output-map operations by hand.
|
||
"""
|
||
workspace = self._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 in steps:
|
||
raise ValueError(f"draft step {step_id!r} already exists")
|
||
|
||
spec = self.context.specs.get_qualified_spec(capability_name)
|
||
output_schema = (
|
||
spec.output_schema_contract or spec.output_model.model_json_schema()
|
||
)
|
||
state_schema = workspace.draft.get("state_schema", {})
|
||
if not isinstance(state_schema, dict):
|
||
raise ValueError("draft state_schema must be an object")
|
||
|
||
input_map = input_map or {}
|
||
bind_outputs = bind_outputs or {}
|
||
projected_state_schema = state_schema
|
||
for output_field, state_path in bind_outputs.items():
|
||
state_field = _state_root_field(state_path)
|
||
projected_state_schema = project_output_property_to_state_schema(
|
||
state_schema=projected_state_schema,
|
||
output_schema=output_schema,
|
||
output_field=output_field,
|
||
state_field=state_field,
|
||
)
|
||
|
||
patch: list[dict[str, Any]] = [
|
||
{
|
||
"op": "add",
|
||
"path": f"/steps/{_escape_json_pointer(step_id)}",
|
||
"value": {
|
||
"use": capability_name,
|
||
"input": _draft_input_bindings_payload(input_map, {}),
|
||
"output": _draft_output_bindings_payload(bind_outputs),
|
||
},
|
||
},
|
||
{
|
||
"op": "add",
|
||
"path": f"/routes/{_escape_json_pointer(step_id)}",
|
||
"value": {route_outcome: route_to},
|
||
},
|
||
]
|
||
if projected_state_schema != state_schema:
|
||
patch.insert(
|
||
0,
|
||
{
|
||
"op": "replace",
|
||
"path": "/state_schema",
|
||
"value": projected_state_schema,
|
||
},
|
||
)
|
||
if route_from_step is not None:
|
||
patch.append(
|
||
{
|
||
"op": "add",
|
||
"path": (
|
||
f"/routes/{_escape_json_pointer(route_from_step)}/"
|
||
f"{_escape_json_pointer(route_from_outcome)}"
|
||
),
|
||
"value": step_id,
|
||
}
|
||
)
|
||
|
||
return await self.patch_draft_workspace(
|
||
workspace_id=workspace_id,
|
||
revision=revision,
|
||
patch=patch,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Run API tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_api/test_drafts_service.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 6: Commit API task**
|
||
|
||
```bash
|
||
git add src/wf_api/drafts.py tests/wf_api/test_drafts_service.py
|
||
git commit -m "feat: add draft step capability helper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: API Surface, Facade, RPC, And Client
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_api/surface.py`
|
||
- Modify: `src/wf_api/service.py`
|
||
- 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 failing RPC app test**
|
||
|
||
In `tests/wf_transport_rpc_http/test_app.py`, extend the existing focused draft edit test or add a new test near it:
|
||
|
||
```python
|
||
async def test_rpc_draft_workspace_add_step_from_capability(
|
||
app_client,
|
||
rpc_server,
|
||
) -> None:
|
||
await app_client.post_json(
|
||
method="workflow.draft_workspaces.create_from_capability",
|
||
params={
|
||
"workspace_id": "add_step_ws",
|
||
"capability_name": "fixture.default.echo",
|
||
"name": "add_step",
|
||
},
|
||
)
|
||
|
||
response = await app_client.post_json(
|
||
method="workflow.draft_workspaces.add_step_from_capability",
|
||
params={
|
||
"workspace_id": "add_step_ws",
|
||
"revision": 1,
|
||
"step_id": "second",
|
||
"capability_name": "fixture.default.echo",
|
||
"route_from_step": "call",
|
||
"route_from_outcome": "ok",
|
||
"route_outcome": "ok",
|
||
"route_to": "__end__",
|
||
"input_map": {"input.text": "text"},
|
||
"bind_outputs": {"text": "state.second_text"},
|
||
},
|
||
)
|
||
|
||
result = response["result"]
|
||
assert result["revision"] == 2
|
||
assert result["draft"]["steps"]["second"]["use"] == "fixture.default.echo"
|
||
assert result["draft"]["routes"]["call"]["ok"] == "second"
|
||
assert result["draft"]["routes"]["second"]["ok"] == "__end__"
|
||
```
|
||
|
||
Adjust fixture capability names only if this file uses a different fixture id; keep the assertion shape.
|
||
|
||
- [ ] **Step 2: Add failing RPC client test**
|
||
|
||
In `tests/wf_transport_rpc_http/test_client.py`, add near existing draft client tests:
|
||
|
||
```python
|
||
async def test_rpc_client_draft_workspace_add_step_from_capability(
|
||
rpc_client,
|
||
) -> None:
|
||
result = await rpc_client.add_step_from_capability(
|
||
workspace_id="add_step_ws",
|
||
revision=1,
|
||
step_id="second",
|
||
capability_name="fixture.default.echo",
|
||
route_from_step="call",
|
||
route_from_outcome="ok",
|
||
route_outcome="ok",
|
||
route_to="__end__",
|
||
input_map={"input.text": "text"},
|
||
bind_outputs={"text": "state.second_text"},
|
||
)
|
||
|
||
assert result["ok"] is True
|
||
```
|
||
|
||
If this test file uses a fake transport that records method names instead of returning real workspace data, assert that the recorded method is `workflow.draft_workspaces.add_step_from_capability` and the params contain `bind_outputs`.
|
||
|
||
- [ ] **Step 3: Run failing RPC tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: fail because RPC DTO/method/client do not exist.
|
||
|
||
- [ ] **Step 4: Add surface and facade method**
|
||
|
||
In `src/wf_api/surface.py`, add to `WorkflowDraftSurface`:
|
||
|
||
```python
|
||
async def add_step_from_capability(
|
||
self,
|
||
*,
|
||
workspace_id: str,
|
||
revision: int,
|
||
step_id: str,
|
||
capability_name: str,
|
||
route_from_step: str | None = None,
|
||
route_from_outcome: str = "ok",
|
||
route_outcome: str = "ok",
|
||
route_to: str = "__end__",
|
||
input_map: dict[str, str] | None = None,
|
||
bind_outputs: dict[str, str] | None = None,
|
||
) -> dict[str, Any]: ...
|
||
```
|
||
|
||
In `src/wf_api/service.py`, add the delegate near other draft delegates:
|
||
|
||
```python
|
||
async def add_step_from_capability(
|
||
self,
|
||
*,
|
||
workspace_id: str,
|
||
revision: int,
|
||
step_id: str,
|
||
capability_name: str,
|
||
route_from_step: str | None = None,
|
||
route_from_outcome: str = "ok",
|
||
route_outcome: str = "ok",
|
||
route_to: str = "__end__",
|
||
input_map: dict[str, str] | None = None,
|
||
bind_outputs: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
return await self.drafts.add_step_from_capability(
|
||
workspace_id=workspace_id,
|
||
revision=revision,
|
||
step_id=step_id,
|
||
capability_name=capability_name,
|
||
route_from_step=route_from_step,
|
||
route_from_outcome=route_from_outcome,
|
||
route_outcome=route_outcome,
|
||
route_to=route_to,
|
||
input_map=input_map,
|
||
bind_outputs=bind_outputs,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Add RPC DTO**
|
||
|
||
In `src/wf_transport_rpc_http/models.py`, add near `BindOutputToStateParams`:
|
||
|
||
```python
|
||
class AddStepFromCapabilityParams(RpcParamsModel):
|
||
workspace_id: str = Field(min_length=1)
|
||
revision: int = Field(ge=1)
|
||
step_id: str = Field(min_length=1)
|
||
capability_name: str = Field(min_length=1)
|
||
route_from_step: str | None = None
|
||
route_from_outcome: str = Field(default="ok", min_length=1)
|
||
route_outcome: str = Field(default="ok", min_length=1)
|
||
route_to: str = Field(default="__end__", min_length=1)
|
||
input_map: dict[str, str] = Field(default_factory=dict)
|
||
bind_outputs: dict[str, str] = Field(default_factory=dict)
|
||
```
|
||
|
||
Also export it from `src/wf_transport_rpc_http/__init__.py` if that file exports param models.
|
||
|
||
- [ ] **Step 6: Register RPC method**
|
||
|
||
In `src/wf_transport_rpc_http/methods/drafts.py`, import `AddStepFromCapabilityParams` and add:
|
||
|
||
```python
|
||
@entrypoint.method(
|
||
name="workflow.draft_workspaces.add_step_from_capability",
|
||
errors=[WorkflowRpcError],
|
||
)
|
||
async def workflow_draft_workspaces_add_step_from_capability(
|
||
params: AddStepFromCapabilityParams = RpcParams(),
|
||
) -> dict[str, Any]:
|
||
try:
|
||
return await server.api.add_step_from_capability(
|
||
workspace_id=params.workspace_id,
|
||
revision=params.revision,
|
||
step_id=params.step_id,
|
||
capability_name=params.capability_name,
|
||
route_from_step=params.route_from_step,
|
||
route_from_outcome=params.route_from_outcome,
|
||
route_outcome=params.route_outcome,
|
||
route_to=params.route_to,
|
||
input_map=params.input_map,
|
||
bind_outputs=params.bind_outputs,
|
||
)
|
||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||
raise_workflow_rpc_error(exc)
|
||
```
|
||
|
||
- [ ] **Step 7: Add RPC client method**
|
||
|
||
In `src/wf_transport_rpc_http/client/drafts.py`, add:
|
||
|
||
```python
|
||
async def add_step_from_capability(
|
||
self,
|
||
*,
|
||
workspace_id: str,
|
||
revision: int,
|
||
step_id: str,
|
||
capability_name: str,
|
||
route_from_step: str | None = None,
|
||
route_from_outcome: str = "ok",
|
||
route_outcome: str = "ok",
|
||
route_to: str = "__end__",
|
||
input_map: dict[str, str] | None = None,
|
||
bind_outputs: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
return await self._request(
|
||
"workflow.draft_workspaces.add_step_from_capability",
|
||
{
|
||
"workspace_id": workspace_id,
|
||
"revision": revision,
|
||
"step_id": step_id,
|
||
"capability_name": capability_name,
|
||
"route_from_step": route_from_step,
|
||
"route_from_outcome": route_from_outcome,
|
||
"route_outcome": route_outcome,
|
||
"route_to": route_to,
|
||
"input_map": input_map or {},
|
||
"bind_outputs": bind_outputs or {},
|
||
},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 8: Run RPC tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 9: Commit RPC task**
|
||
|
||
```bash
|
||
git add src/wf_api/surface.py src/wf_api/service.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/drafts.py src/wf_transport_rpc_http/client/drafts.py src/wf_transport_rpc_http/__init__.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
|
||
git commit -m "feat: expose draft add-step helper over rpc"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: MCP Workflow Tool
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_mcp/workflow_surface/models.py`
|
||
- Modify: `src/wf_mcp/workflow_surface/tools.py`
|
||
- Test: `tests/wf_mcp/server/test_config.py`
|
||
|
||
- [ ] **Step 1: Add failing MCP tool schema test**
|
||
|
||
In `tests/wf_mcp/server/test_config.py`, extend the workflow tool schema assertion:
|
||
|
||
```python
|
||
assert "wf.workflow.add_step_from_capability" in tools_by_name
|
||
add_step_schema = tools_by_name["wf.workflow.add_step_from_capability"][
|
||
"inputSchema"
|
||
]
|
||
request_schema = add_step_schema["properties"]["request"]
|
||
assert "capability_name" in str(request_schema)
|
||
assert "bind_outputs" in str(request_schema)
|
||
```
|
||
|
||
Keep the exact helper variables used in the existing test file.
|
||
|
||
- [ ] **Step 2: Run failing MCP test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_mcp/server/test_config.py -q -k "workflow"
|
||
```
|
||
|
||
Expected: fail because the tool is missing.
|
||
|
||
- [ ] **Step 3: Add MCP request model**
|
||
|
||
In `src/wf_mcp/workflow_surface/models.py`, add near other draft request models:
|
||
|
||
```python
|
||
class AddStepFromCapabilityRequest(BaseModel):
|
||
workspace_id: str = Field(description="Draft workspace id.")
|
||
revision: int = Field(ge=1, description="Expected workspace revision.")
|
||
step_id: str = Field(description="New draft step id.")
|
||
capability_name: str = Field(description="Qualified capability name.")
|
||
route_from_step: str | None = Field(
|
||
default=None,
|
||
description="Optional existing step whose outcome should route to the new step.",
|
||
)
|
||
route_from_outcome: str = Field(
|
||
default="ok",
|
||
description="Outcome on route_from_step that should route to the new step.",
|
||
)
|
||
route_outcome: str = Field(
|
||
default="ok",
|
||
description="Outcome emitted by the new step.",
|
||
)
|
||
route_to: str = Field(
|
||
default="__end__",
|
||
description="Target step id or __end__ for the new step outcome.",
|
||
)
|
||
input_map: dict[str, str] = Field(
|
||
default_factory=dict,
|
||
description="Graph source path to node-local target field.",
|
||
)
|
||
bind_outputs: dict[str, str] = Field(
|
||
default_factory=dict,
|
||
description="Node-local output field to state path with schema projection.",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Register MCP tool**
|
||
|
||
In `src/wf_mcp/workflow_surface/tools.py`, import the request model and register:
|
||
|
||
```python
|
||
@mcp.tool(
|
||
name="wf.workflow.add_step_from_capability",
|
||
description=(
|
||
"Add one capability-backed draft step with explicit route, input, "
|
||
"and output-to-state binding hints."
|
||
),
|
||
)
|
||
async def add_step_from_capability(
|
||
request: AddStepFromCapabilityRequest,
|
||
) -> DraftWorkspaceResult:
|
||
return DraftWorkspaceResult(
|
||
result=await handlers.add_step_from_capability(
|
||
workspace_id=request.workspace_id,
|
||
revision=request.revision,
|
||
step_id=request.step_id,
|
||
capability_name=request.capability_name,
|
||
route_from_step=request.route_from_step,
|
||
route_from_outcome=request.route_from_outcome,
|
||
route_outcome=request.route_outcome,
|
||
route_to=request.route_to,
|
||
input_map=request.input_map,
|
||
bind_outputs=request.bind_outputs,
|
||
)
|
||
)
|
||
```
|
||
|
||
Use the exact result wrapper pattern already used by adjacent draft tools.
|
||
|
||
- [ ] **Step 5: Run MCP test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_mcp/server/test_config.py -q -k "workflow"
|
||
```
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 6: Commit MCP task**
|
||
|
||
```bash
|
||
git add src/wf_mcp/workflow_surface/models.py src/wf_mcp/workflow_surface/tools.py tests/wf_mcp/server/test_config.py
|
||
git commit -m "feat: expose draft add-step helper to mcp"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: CLI Command
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/wf_cli/commands/drafts.py`
|
||
- Test: `tests/wf_cli/test_app.py`
|
||
- Test: `tests/wf_cli/test_remote_target.py`
|
||
|
||
- [ ] **Step 1: Add failing CLI help test**
|
||
|
||
In `tests/wf_cli/test_app.py`, add:
|
||
|
||
```python
|
||
def test_wf_draft_add_step_from_capability_help_explains_explicit_wiring(
|
||
runner,
|
||
) -> None:
|
||
result = runner.invoke(app, ["draft", "add-step-from-capability", "--help"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "--from-step" in result.output
|
||
assert "--bind-output" in result.output
|
||
assert "does not guess" in result.output
|
||
```
|
||
|
||
Adjust fixture names if this file uses a different runner/app setup.
|
||
|
||
- [ ] **Step 2: Add failing CLI remote routing test**
|
||
|
||
In `tests/wf_cli/test_remote_target.py`, add near draft remote target tests:
|
||
|
||
```python
|
||
def test_wf_draft_add_step_from_capability_uses_rpc_target(
|
||
cli_runner,
|
||
remote_context,
|
||
) -> None:
|
||
result = cli_runner.invoke(
|
||
app,
|
||
[
|
||
"--rpc-url",
|
||
remote_context.url,
|
||
"draft",
|
||
"add-step-from-capability",
|
||
"add_step_ws",
|
||
"--revision",
|
||
"1",
|
||
"--step",
|
||
"second",
|
||
"--capability",
|
||
"fixture.default.echo",
|
||
"--from-step",
|
||
"call",
|
||
"--from-outcome",
|
||
"ok",
|
||
"--outcome",
|
||
"ok",
|
||
"--to",
|
||
"__end__",
|
||
"--input",
|
||
"input.text=text",
|
||
"--bind-output",
|
||
"text=state.second_text",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert "second" in result.output
|
||
```
|
||
|
||
Adapt fixture names and expected output shape to this test file’s existing helper conventions.
|
||
|
||
- [ ] **Step 3: Run failing CLI tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: fail because CLI command is missing.
|
||
|
||
- [ ] **Step 4: Add CLI command**
|
||
|
||
In `src/wf_cli/commands/drafts.py`, add after `bind_output_to_state`:
|
||
|
||
```python
|
||
@app.command("add-step-from-capability")
|
||
def add_step_from_capability(
|
||
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_id: Annotated[str, typer.Option("--step", help="New draft step id.")],
|
||
capability_name: Annotated[
|
||
str, typer.Option("--capability", help="Qualified capability name.")
|
||
],
|
||
route_from_step: Annotated[
|
||
str | None,
|
||
typer.Option(
|
||
"--from-step",
|
||
help="Optional existing step whose outcome should route to this step.",
|
||
),
|
||
] = None,
|
||
route_from_outcome: Annotated[
|
||
str,
|
||
typer.Option("--from-outcome", help="Outcome on --from-step."),
|
||
] = "ok",
|
||
route_outcome: Annotated[
|
||
str,
|
||
typer.Option("--outcome", help="Outcome emitted by the new step."),
|
||
] = "ok",
|
||
route_to: Annotated[
|
||
str,
|
||
typer.Option("--to", help="Target step id or __end__ for the new step."),
|
||
] = "__end__",
|
||
input_mapping: Annotated[
|
||
list[str] | None,
|
||
typer.Option(
|
||
"--input",
|
||
help="Input binding SOURCE=LOCAL_TARGET. Repeat for multiple inputs.",
|
||
),
|
||
] = None,
|
||
output_mapping: Annotated[
|
||
list[str] | None,
|
||
typer.Option(
|
||
"--bind-output",
|
||
help=(
|
||
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
|
||
"projection. Repeat for multiple outputs."
|
||
),
|
||
),
|
||
] = None,
|
||
) -> None:
|
||
"""Add one capability step with explicit route, input, and output wiring.
|
||
|
||
This command does not guess missing maps. Pass the route and bindings you
|
||
want, then run `wf draft validate <workspace_id>`.
|
||
"""
|
||
input_map = _parse_map_flags(input_mapping)
|
||
bind_outputs = _parse_map_flags(output_mapping)
|
||
context = load_cli_context(ctx)
|
||
emit_json(
|
||
run_cli_operation(
|
||
context,
|
||
context.handlers.add_step_from_capability(
|
||
workspace_id=workspace_id,
|
||
revision=revision,
|
||
step_id=step_id,
|
||
capability_name=capability_name,
|
||
route_from_step=route_from_step,
|
||
route_from_outcome=route_from_outcome,
|
||
route_outcome=route_outcome,
|
||
route_to=route_to,
|
||
input_map=input_map,
|
||
bind_outputs=bind_outputs,
|
||
),
|
||
)
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Run CLI tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q -k "add_step_from_capability"
|
||
```
|
||
|
||
Expected: pass.
|
||
|
||
- [ ] **Step 6: Commit CLI task**
|
||
|
||
```bash
|
||
git add src/wf_cli/commands/drafts.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py
|
||
git commit -m "feat: add draft add-step cli"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Docs, Skills, Roadmap
|
||
|
||
**Files:**
|
||
|
||
- Modify: `docs/wf_cli.md`
|
||
- Modify: `skills/wf-cli/SKILL.md`
|
||
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
|
||
- Modify: `skills/wf-workflow/references/workflow-lifecycle.md`
|
||
- Modify: `docs/current_roadmap.md`
|
||
|
||
- [ ] **Step 1: Update CLI docs**
|
||
|
||
In `docs/wf_cli.md`, add a short section near other draft focused helpers:
|
||
|
||
````markdown
|
||
### Add A Capability Step To A Draft
|
||
|
||
Use `wf draft add-step-from-capability` when adding a new capability-backed step
|
||
to an existing draft. The command is explicit: it does not guess missing maps.
|
||
|
||
```bash
|
||
wf draft add-step-from-capability report_ws \
|
||
--revision 3 \
|
||
--step render \
|
||
--capability local.report.render_markdown_report \
|
||
--from-step extract \
|
||
--from-outcome ok \
|
||
--outcome ok \
|
||
--to __end__ \
|
||
--input state.title=title \
|
||
--bind-output markdown=state.markdown
|
||
```
|
||
|
||
Run `wf draft validate report_ws` after adding the step. If validation returns
|
||
a `repair_hint`, prefer the focused helper in that hint before JSON Patch.
|
||
````
|
||
|
||
- [ ] **Step 2: Update CLI skill**
|
||
|
||
In `skills/wf-cli/SKILL.md`, add a rule under draft workflow guidance:
|
||
|
||
```markdown
|
||
- To add a capability step, prefer `wf draft add-step-from-capability` over raw
|
||
JSON Patch when the route, input bindings, and output-to-state bindings are
|
||
known. It is explicit and does not guess missing maps.
|
||
```
|
||
|
||
- [ ] **Step 3: Update workflow draft reference**
|
||
|
||
In `skills/wf-workflow/references/draft-workspaces.md`, add the command to the focused helper list and CLI block:
|
||
|
||
```markdown
|
||
- `add_step_from_capability`
|
||
```
|
||
|
||
```bash
|
||
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --outcome ok --to <next-or-__end__> --input input.text=text --bind-output result=state.result
|
||
```
|
||
|
||
Add this explanatory paragraph:
|
||
|
||
```markdown
|
||
Use `add-step-from-capability` when inserting a new capability step. It can set
|
||
the incoming edge, outgoing edge, input map, and output-to-state schema/binding
|
||
in one revision. It still requires explicit choices; if you do not know a map,
|
||
inspect the capability or run validation rather than guessing.
|
||
```
|
||
|
||
- [ ] **Step 4: Update workflow lifecycle skill**
|
||
|
||
In `skills/wf-workflow/references/workflow-lifecycle.md`, add after the draft patch/edit step:
|
||
|
||
```markdown
|
||
When adding a new capability-backed step, prefer:
|
||
|
||
```bash
|
||
wf draft add-step-from-capability ...
|
||
wf draft validate <workspace_id>
|
||
```
|
||
|
||
Use raw `wf draft patch` only when changing structure that no focused helper
|
||
covers.
|
||
```
|
||
```
|
||
|
||
Fix fence nesting if this lands inside another code block.
|
||
|
||
- [ ] **Step 5: Update roadmap**
|
||
|
||
In `docs/current_roadmap.md`, add under Priority 1 completed draft UX bullets:
|
||
|
||
```markdown
|
||
- Completed: `wf draft add-step-from-capability` inserts one explicit
|
||
capability-backed step with route, input, and output-to-state schema/binding
|
||
wiring in a single revision, reducing brittle JSON Patch authoring for
|
||
multi-step workflows.
|
||
```
|
||
|
||
- [ ] **Step 6: Commit docs task**
|
||
|
||
```bash
|
||
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md skills/wf-workflow/references/workflow-lifecycle.md docs/current_roadmap.md
|
||
git commit -m "docs: document draft add-step helper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Final Verification
|
||
|
||
**Files:**
|
||
|
||
- All files changed above.
|
||
|
||
- [ ] **Step 1: Run focused tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
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_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py -q -k "add_step_from_capability or focused_edit or workflow"
|
||
```
|
||
|
||
Expected: all selected tests pass.
|
||
|
||
- [ ] **Step 2: Run ruff**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run ruff check src/wf_api/drafts.py src/wf_api/service.py src/wf_api/surface.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/drafts.py src/wf_transport_rpc_http/client/drafts.py src/wf_transport_rpc_http/__init__.py src/wf_mcp/workflow_surface/models.py src/wf_mcp/workflow_surface/tools.py src/wf_cli/commands/drafts.py 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_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py
|
||
```
|
||
|
||
Expected: `All checks passed!`
|
||
|
||
- [ ] **Step 3: Run format check**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run ruff format --check src/wf_api/drafts.py src/wf_api/service.py src/wf_api/surface.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/drafts.py src/wf_transport_rpc_http/client/drafts.py src/wf_transport_rpc_http/__init__.py src/wf_mcp/workflow_surface/models.py src/wf_mcp/workflow_surface/tools.py src/wf_cli/commands/drafts.py 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_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py
|
||
```
|
||
|
||
Expected: all files already formatted.
|
||
|
||
- [ ] **Step 4: Run typecheck**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
uv run basedpyright --level error src/wf_api/drafts.py src/wf_api/service.py src/wf_api/surface.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/drafts.py src/wf_transport_rpc_http/client/drafts.py src/wf_transport_rpc_http/__init__.py src/wf_mcp/workflow_surface/models.py src/wf_mcp/workflow_surface/tools.py src/wf_cli/commands/drafts.py 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_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py
|
||
```
|
||
|
||
Expected: `0 errors, 0 warnings, 0 notes`.
|
||
|
||
- [ ] **Step 5: Check whitespace**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git diff --check
|
||
```
|
||
|
||
Expected: no whitespace errors. CRLF warnings on Windows are acceptable.
|
||
|
||
- [ ] **Step 6: Commit final verification note if docs changed after review**
|
||
|
||
Only if verification required doc fixes:
|
||
|
||
```bash
|
||
git add <changed-files>
|
||
git commit -m "fix: polish draft add-step helper docs"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- This plan covers API, facade, protocol, RPC, client, MCP, CLI, docs, skills, and roadmap.
|
||
- The helper is deliberately explicit and does not infer missing bindings.
|
||
- The operation edits the draft in one revision, avoiding revision conflicts caused by chaining multiple helper calls.
|
||
- Schema projection uses the existing `project_output_property_to_state_schema()` helper rather than hand-rolling JSON Schema reference handling.
|
||
- The command names avoid overloaded `after`; `--from-step` and `--to` mirror graph edge direction.
|