feat: accept nested local capability maps

This commit is contained in:
lda
2026-07-22 08:46:15 +07:00 Verified
parent 58e4364012
commit cde0a3c14f
11 changed files with 229 additions and 30 deletions
@@ -432,7 +432,7 @@
- Preserves: `add_step_from_capability(..., input_map: dict[str, str] | None, ...)` and all transport envelopes.
- Produces: validated rootless local map targets such as `report.title` in CLI commands.
- [ ] **Step 1: Add failing capability-add API coverage**
- [x] **Step 1: Add failing capability-add API coverage**
In `tests/wf_api/test_drafts_service.py`, add a test that creates an empty-schema draft and calls:
@@ -450,7 +450,7 @@
Assert revision 2, stored target `report.title`, and projected workflow input schema `title: string`. Validate or compile the resulting draft through the existing draft validation API and assert `status == "valid"`.
- [ ] **Step 2: Add failing CLI parser and help tests**
- [x] **Step 2: Add failing CLI parser and help tests**
In `tests/wf_cli/test_app.py`, add assertions that:
@@ -461,7 +461,7 @@
- `wf draft bind --help` describes explicit rooted endpoints such as `local.report.title`;
- set-input and capability-add help describe rootless local paths such as `report.title`.
- [ ] **Step 3: Add failing transport preservation tests**
- [x] **Step 3: Add failing transport preservation tests**
In `tests/wf_transport_rpc_http/test_app.py` and `tests/wf_transport_rpc_http/test_client.py`, extend the existing bind and add-capability tests with nested values. Assert request delegation and stored results preserve:
@@ -473,7 +473,7 @@
In `tests/wf_cli/test_remote_target.py`, assert the remote CLI emits the existing JSON-RPC method names and unchanged nested strings. Do not add new RPC models or methods.
- [ ] **Step 4: Run focused tests and confirm nested projection/help failures**
- [x] **Step 4: Run focused tests and confirm nested projection/help failures**
Run:
@@ -483,7 +483,7 @@
Expected: the add-capability test fails because nested local input paths are skipped; CLI validation/help assertions fail until implemented.
- [ ] **Step 5: Project nested capability input paths instead of skipping them**
- [x] **Step 5: Project nested capability input paths instead of skipping them**
In `WorkflowDraftAuthoringApi.add_step_from_capability`:
@@ -495,7 +495,7 @@
Invalid canonical paths may retain current request-layer behavior, but a valid nested local path must never silently skip schema projection.
- [ ] **Step 6: Validate rootless local targets and clarify help**
- [x] **Step 6: Validate rootless local targets and clarify help**
In `_parse_step_input_map_flags`, retain the rooted-target repair first, then validate each rootless target:
@@ -518,7 +518,7 @@
Update only descriptions in `BindDraftRequest` and the capability-add request model in `src/wf_mcp/workflow_surface/models.py`; do not alter fields or add validation models.
- [ ] **Step 7: Run API, RPC, client, and CLI tests**
- [x] **Step 7: Run API, RPC, client, and CLI tests**
Run:
@@ -531,7 +531,7 @@
Expected: all focused layers pass with no request-schema changes.
- [ ] **Step 8: Commit nested capability-map and surface support**
- [x] **Step 8: Commit nested capability-map and surface support**
```bash
git add src/wf_api/draft_authoring.py src/wf_cli/commands/draft_options.py src/wf_cli/commands/drafts.py src/wf_cli/commands/draft_add.py src/wf_mcp/workflow_surface/models.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
+3 -4
View File
@@ -57,7 +57,6 @@ from .drafts import (
from .operation_context import WorkflowOperationContext
from .schema_projection import (
project_output_property_to_state_schema,
project_property_to_schema_path,
project_schema_path_to_schema_path,
schema_path_exists,
)
@@ -603,7 +602,7 @@ class WorkflowDraftAuthoringApi:
local_parts = LocalPath.parse(local_path).parts
except ValueError:
continue
if source_root not in {"input", "state"} or len(local_parts) != 1:
if source_root not in {"input", "state"}:
continue
schema_key = "input_schema" if source_root == "input" else "state_schema"
target_schema = (
@@ -613,10 +612,10 @@ class WorkflowDraftAuthoringApi:
)
if schema_path_exists(target_schema, source_parts):
continue
projected = project_property_to_schema_path(
projected = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=input_schema,
source_field=local_parts[0],
source_parts=local_parts,
target_parts=source_parts,
allow_existing_equivalent=True,
)
+4 -4
View File
@@ -37,7 +37,6 @@ from wf_core.models.steps import (
from wf_core.models.workflow_refs import WorkflowRef
from .draft_options import (
_parse_map_flags,
_parse_output_map_flags,
_parse_route_flags,
_parse_step_input_map_flags,
@@ -149,11 +148,12 @@ def add_step_from_capability(
`wf draft add capability report_ws --revision 1 --step render
--capability local.report.render --route ok=__end__`
Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary`
Local input targets are rootless node-local paths. Repeat the flag for
multiple bindings:
`--input state.title=report.title --input state.summary=report.summary`
`--bind-output title=state.title --bind-output summary=state.summary`
"""
input_map = _parse_map_flags(input_mapping)
input_map = _parse_step_input_map_flags(input_mapping, option_name="--input")
bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route)
context = load_cli_context(ctx)
+9 -2
View File
@@ -64,7 +64,7 @@ def _parse_output_map_flags(
def _parse_step_input_map_flags(
values: list[str] | None, *, option_name: str = "--map"
) -> dict[str, str]:
"""Parse graph-source to bare-local input mappings for one draft step."""
"""Parse graph-source to rootless node-local mappings for one draft step."""
parsed = _parse_assignment_flags(
values,
option_name=option_name,
@@ -74,9 +74,16 @@ def _parse_step_input_map_flags(
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
f"{option_name} target must be a bare local field; "
f"{option_name} target must be a rootless node-local path; "
f"use {source}={bare_target}, not {source}={target}"
)
try:
LocalPath.parse(target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"{option_name} target must be a rootless node-local path; "
f"got {target!r}; use report.title or ."
) from exc
return parsed
+14 -3
View File
@@ -399,7 +399,10 @@ def set_step_input_map(
--map entries in one command for a complete replacement. Use --merge only
when adding or updating entries across a later revision.
Targets are bare node-local field names. Use `--map input.text=text`, not
Targets are rootless node-local paths. For example, use
`--map input.title=report.title`, not
`--map input.title=local.report.title`.
Single-field targets remain valid: use `--map input.text=text`, not
`--map input.text=local.text`.
Run `wf draft validate <workspace_id>` after map edits; validation reports
@@ -536,11 +539,17 @@ def bind_draft(
step_id: Annotated[str, typer.Option("--step", help="Draft step id.")],
source_path: Annotated[
str,
typer.Option("--from", help="Source path, for example input.x or local.y."),
typer.Option(
"--from",
help="Explicit source endpoint, such as input.title or local.report.title.",
),
],
target_path: Annotated[
str,
typer.Option("--to", help="Target path, for example local.x or state.y."),
typer.Option(
"--to",
help="Explicit target endpoint, such as local.report.title or state.x.",
),
],
) -> None:
"""Bind a capability step path and project missing schema when needed.
@@ -549,6 +558,8 @@ def bind_draft(
state/output for step outputs. If the workflow schema field already exists,
the command reuses it and updates the step binding. For pure input-map edits
where schema is already known, `wf draft set-input --merge` is also valid.
Bind endpoints are rooted, including nested paths such as
`input.title -> local.report.title`.
Run `wf draft validate <workspace_id>` after this command.
"""
context = load_cli_context(ctx)
+9 -3
View File
@@ -270,10 +270,16 @@ class BindDraftRequest(BaseModel):
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: NonEmptyString = Field(description="Capability-backed draft step id.")
source_path: NonEmptyString = Field(
description="Source path, for example input.x or local.y."
description=(
"Explicit source endpoint, for example input.title or "
"local.report.markdown."
)
)
target_path: NonEmptyString = Field(
description="Target path, for example local.x or state.y."
description=(
"Explicit target endpoint, for example local.report.title or "
"state.report.markdown."
)
)
@@ -303,7 +309,7 @@ class AddStepFromCapabilityRequest(BaseModel):
)
input_map: DraftPathMap = Field(
default_factory=dict,
description="Graph source path to node-local target field.",
description="Graph source path to rootless node-local target path.",
)
bind_outputs: DraftPathMap = Field(
default_factory=dict,
+38
View File
@@ -1423,6 +1423,44 @@ async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
}
@pytest.mark.asyncio
async def test_add_step_from_capability_projects_nested_local_input(
tmp_path: Path,
) -> None:
api, service, authoring = _draft_api(
FileWorkflowArtifactStore(tmp_path / "nested_capability_input"),
register_echo=True,
)
service.register_specs("demo.personal", _nested_report)
draft = _nested_report_draft()
draft["steps"] = {}
draft["routes"] = {}
await api.create_draft_workspace(workspace_id="nested_add", draft=draft)
result = await authoring.add_step_from_capability(
workspace_id="nested_add",
revision=1,
step_id="render",
capability_name="demo.personal.nested_report",
routes={"ok": "__end__"},
input_map={"input.title": "report.title"},
bind_outputs={},
)
workspace = await api.get_draft_workspace(
workspace_id="nested_add", include_draft=True
)
validated = await api.validate_draft_workspace(workspace_id="nested_add")
assert result["revision"] == 2
assert workspace["draft"]["input_schema"]["properties"]["title"]["type"] == (
"string"
)
assert workspace["draft"]["steps"]["render"]["input"] == [
{"target": "report.title", "path": "input.title"}
]
assert validated["status"] == "valid", validated["diagnostics"]
@pytest.mark.asyncio
async def test_add_step_from_capability_rejects_existing_step_id(
tmp_path: Path,
+62 -4
View File
@@ -515,6 +515,7 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
assert "draft validate" in input_help
assert "input.text=text" in input_help
assert "input.text=local.text" in input_help
assert "input.title=report.title" in input_help
assert "replaces the full output map" in output_help
assert "Use --merge only" in output_help
assert "draft validate" in output_help
@@ -535,6 +536,7 @@ def test_wf_draft_bind_help_explains_direction() -> None:
assert "validate" in help_text
assert "project missing schema" in help_text
assert "set-input --merge" in help_text
assert "local.report.title" in help_text
def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
@@ -550,7 +552,7 @@ def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
assert "draft validate" in output
assert "wf draft add capability report_ws" in output
assert "Repeat the flag" in output
assert "--input state.title=title --input state.summary=summary" in output
assert "--input state.title=report.title" in output
assert (
"--bind-output title=state.title --bind-output summary=state.summary" in output
)
@@ -587,7 +589,7 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
"--route",
"ok=__end__",
"--input",
"input.text=text",
"input.text=report.text",
"--bind-output",
"value=state.value",
],
@@ -602,7 +604,7 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
assert call["route_from_step"] == "start"
assert call["route_from_outcome"] == "ok"
assert call["routes"] == {"ok": "__end__"}
assert call["input_map"] == {"input.text": "text"}
assert call["input_map"] == {"input.text": "report.text"}
assert call["bind_outputs"] == {"value": "state.value"}
@@ -1487,6 +1489,62 @@ def test_wf_draft_set_input_rejects_local_prefixed_target() -> None:
assert result.exit_code == 2
output = " ".join(result.output.split())
assert "bare local field" in output
assert "rootless node-local path" in output
assert "input.text=text" in output
assert "input.text=local.text" in output
def test_wf_draft_set_input_accepts_nested_rootless_target(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_map(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"input.title=report.title",
],
)
assert result.exit_code == 0, result.output
assert calls[0]["input_map"] == {"input.title": "report.title"}
def test_wf_draft_set_input_rejects_malformed_local_path(monkeypatch) -> None:
monkeypatch.setattr(
"wf_cli.commands.drafts.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"input.title=report..title",
],
)
assert result.exit_code == 2
assert "rootless node-local path" in result.output
assert "context loaded" not in result.output
+52
View File
@@ -1396,6 +1396,58 @@ def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
]
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
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",
"nested_input_ws",
"--capability",
"wf.std.constant",
"--name",
"nested_input",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"nested_input_ws",
"--revision",
"1",
"--step",
"call",
"--map",
"input.value=payload.value",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "nested_input_ws", "--include-draft"],
)
assert result.exit_code == 0, result.output
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["steps"]["call"]["input"] == [
{"target": "payload.value", "path": "input.value"}
]
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
+2 -2
View File
@@ -802,7 +802,7 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
"workspace_id": "focused_ws",
"revision": 3,
"step_id": "call",
"input_map": {"input.value": "value"},
"input_map": {"input.value": "payload.value"},
},
)
output_mapped = await _rpc(
@@ -866,7 +866,7 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
assert draft["routes"]["call"]["ok"] == "__end__"
assert draft["steps"]["call"]["input"] == [
{
"target": "value",
"target": "payload.value",
"path": "input.value",
},
{
@@ -760,6 +760,34 @@ async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) ->
assert result["status"] == "valid"
async def test_rpc_client_preserves_nested_local_path_strings() -> 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()
await client.bind_draft(
workspace_id="ws",
revision=1,
step_id="render",
source_path="input.title",
target_path="local.report.title",
)
await client.add_step_from_capability(
workspace_id="ws",
revision=2,
step_id="render",
capability_name="demo.report",
input_map={"input.title": "report.title"},
)
assert calls[0]["params"]["target_path"] == "local.report.title"
assert calls[1]["params"]["input_map"] == {"input.title": "report.title"}
@pytest.mark.parametrize(
("step_id", "step", "expected_wire"),
[