feat: accept nested local capability maps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user