feat: group draft add commands

This commit is contained in:
lda
2026-07-20 17:14:11 +07:00 Verified
parent 518def05f0
commit 63e7a17f9e
5 changed files with 350 additions and 164 deletions
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from typing import Annotated
import typer
from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
from .draft_options import _parse_map_flags, _parse_output_map_flags, _parse_route_flags
app = typer.Typer(
name="add",
help="Add one typed step to a draft workspace.",
no_args_is_help=True,
)
@app.command("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: Annotated[
list[str] | None,
typer.Option(
"--route",
help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
),
] = None,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help=(
"Input binding SOURCE=LOCAL_TARGET. Repeat the flag for each "
"input; do not put multiple mappings after one --input."
),
),
] = None,
output_mapping: Annotated[
list[str] | None,
typer.Option(
"--bind-output",
help=(
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
"projection. Repeat the flag for each output; do not put "
"multiple mappings after one --bind-output."
),
),
] = None,
) -> None:
"""Add a capability step; this also projects its schemas and bindings.
This command does not guess missing maps. Pass the route and bindings you
want, then run `wf draft validate <workspace_id>`.
Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary`
`--bind-output title=state.title --bind-output summary=state.summary`
"""
input_map = _parse_map_flags(input_mapping)
bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route)
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,
routes=routes or None,
input_map=input_map,
bind_outputs=bind_outputs,
),
)
)
# These subgroups establish the public command boundary for later task slices.
# They intentionally have no command bodies until their typed options are ready.
for _name in (
"interrupt",
"foreach",
"join",
"end",
"when",
"choose",
"match",
"subgraph",
):
app.add_typer(
typer.Typer(
name=_name,
help=f"Add a {_name} step (available in a later CLI task).",
no_args_is_help=True,
),
name=_name,
)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import typer
from wf_api.surface import RouteSource
from wf_core.paths import LocalPath, PathResolutionError, StatePath
def _parse_assignment_flags(
values: list[str] | None,
*,
option_name: str,
expected: str,
) -> dict[str, str]:
parsed: dict[str, str] = {}
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter(f"{option_name} must use {expected}")
if source in parsed:
raise typer.BadParameter(f"duplicate {option_name} for {source!r}")
parsed[source] = target
return parsed
def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--map",
expected="source=target",
)
def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
parsed = _parse_assignment_flags(
values,
option_name="--bind-output",
expected="LOCAL_OUTPUT=STATE_TARGET",
)
for local_output, state_target in parsed.items():
try:
LocalPath.parse(local_output)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output source {local_output!r} must be a node-local "
"output path such as value or ."
) from exc
try:
StatePath.parse(state_target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output target {state_target!r} must be a state path "
"such as state.value"
) from exc
return parsed
def _parse_step_input_map_flags(values: list[str] | None) -> dict[str, str]:
"""Parse graph-source to bare-local input mappings for one draft step."""
parsed = _parse_map_flags(values)
for source, target in parsed.items():
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
"--map target must be a bare local field; "
f"use {source}={bare_target}, not {source}={target}"
)
return parsed
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--route",
expected="OUTCOME=TARGET",
)
def parse_json_file(path: Path, *, option_name: str) -> Any:
"""Read one structured CLI value and report file/JSON failures as option errors."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise typer.BadParameter(f"{option_name}: cannot read {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise typer.BadParameter(
f"{option_name}: invalid JSON in {path}: {exc.msg}"
) from exc
def route_source(from_step: str | None, from_outcome: str | None) -> RouteSource | None:
if from_step is None:
if from_outcome is not None:
raise typer.BadParameter("--from-outcome requires --from-step")
return None
return RouteSource(step_id=from_step, outcome=from_outcome or "ok")
+7 -154
View File
@@ -6,88 +6,23 @@ from typing import Annotated, Literal
import typer
from wf_cli.commands import draft_add
from wf_cli.commands.draft_options import (
_parse_map_flags,
_parse_route_flags,
_parse_step_input_map_flags,
)
from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_value
from wf_cli.remote_errors import run_cli_operation
from wf_core.paths import LocalPath, PathResolutionError, StatePath
def _parse_assignment_flags(
values: list[str] | None,
*,
option_name: str,
expected: str,
) -> dict[str, str]:
parsed: dict[str, str] = {}
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter(f"{option_name} must use {expected}")
if source in parsed:
raise typer.BadParameter(f"duplicate {option_name} for {source!r}")
parsed[source] = target
return parsed
def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--map",
expected="source=target",
)
def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
parsed = _parse_assignment_flags(
values,
option_name="--bind-output",
expected="LOCAL_OUTPUT=STATE_TARGET",
)
for local_output, state_target in parsed.items():
try:
LocalPath.parse(local_output)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output source {local_output!r} must be a node-local "
"output path such as value or ."
) from exc
try:
StatePath.parse(state_target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output target {state_target!r} must be a state path "
"such as state.value"
) from exc
return parsed
def _parse_step_input_map_flags(values: list[str] | None) -> dict[str, str]:
"""Parse graph-source to bare-local input mappings for one draft step."""
parsed = _parse_map_flags(values)
for source, target in parsed.items():
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
"--map target must be a bare local field; "
f"use {source}={bare_target}, not {source}={target}"
)
return parsed
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--route",
expected="OUTCOME=TARGET",
)
app = typer.Typer(
name="draft",
help="Create, inspect, patch, validate, and save draft workflows.",
no_args_is_help=True,
)
app.add_typer(draft_add.app, name="add")
@app.command("list")
@@ -443,88 +378,6 @@ def bind_draft(
)
@app.command("add-step")
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: Annotated[
list[str] | None,
typer.Option(
"--route",
help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
),
] = None,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help=(
"Input binding SOURCE=LOCAL_TARGET. Repeat the flag for each "
"input; do not put multiple mappings after one --input."
),
),
] = None,
output_mapping: Annotated[
list[str] | None,
typer.Option(
"--bind-output",
help=(
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
"projection. Repeat the flag for each output; do not put "
"multiple mappings after one --bind-output."
),
),
] = None,
) -> None:
"""Add one capability-backed 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>`.
Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary`
`--bind-output title=state.title --bind-output summary=state.summary`
"""
input_map = _parse_map_flags(input_mapping)
bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route)
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,
routes=routes or None,
input_map=input_map,
bind_outputs=bind_outputs,
),
)
)
@app.command("branch")
def branch_draft(
ctx: typer.Context,
+101 -3
View File
@@ -1,12 +1,36 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
import typer
from typer.testing import CliRunner
from wf_cli.app import app
from wf_cli.commands.draft_options import parse_json_file, route_source
runner = CliRunner()
def test_draft_options_parse_json_file_reports_invalid_input(tmp_path) -> None:
path = tmp_path / "invalid.json"
path.write_text("{", encoding="utf-8")
with pytest.raises(typer.BadParameter, match="--schema-file: invalid JSON"):
parse_json_file(path, option_name="--schema-file")
def test_draft_options_route_source_requires_an_incoming_step() -> None:
with pytest.raises(typer.BadParameter, match="--from-outcome requires --from-step"):
route_source(None, "error")
incoming = route_source("lookup", None)
assert incoming is not None
assert incoming.step_id == "lookup"
assert incoming.outcome == "ok"
def test_wf_help_lists_lifecycle_groups() -> None:
result = runner.invoke(app, ["--help"])
@@ -137,6 +161,27 @@ def test_wf_draft_help_does_not_list_old_create_from_capability() -> None:
assert "create-from-capability" not in result.output
def test_wf_draft_add_help_lists_typed_step_commands_and_removes_flat_command() -> None:
result = runner.invoke(app, ["draft", "add", "--help"])
assert result.exit_code == 0
for name in (
"capability",
"interrupt",
"foreach",
"join",
"end",
"when",
"choose",
"match",
"subgraph",
):
assert name in result.output
removed = runner.invoke(app, ["draft", "add-step", "--help"])
assert removed.exit_code != 0
def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
input_result = runner.invoke(app, ["draft", "set-input", "--help"])
output_result = runner.invoke(app, ["draft", "set-output", "--help"])
@@ -177,8 +222,8 @@ def test_wf_draft_bind_help_explains_direction() -> None:
assert "set-input --merge" in help_text
def test_wf_draft_add_step_help_explains_explicit_wiring() -> None:
result = runner.invoke(app, ["draft", "add-step", "--help"])
def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
result = runner.invoke(app, ["draft", "add", "capability", "--help"])
assert result.exit_code == 0
output = " ".join(result.output.split())
@@ -186,6 +231,8 @@ def test_wf_draft_add_step_help_explains_explicit_wiring() -> None:
assert "--from-step" in output
assert "--bind-output" in output
assert "does not guess" in output
assert "projects its schemas and bindings" in output
assert "draft validate" in output
assert "Repeat the flag" in output
assert "--input state.title=title --input state.summary=summary" in output
assert (
@@ -193,6 +240,56 @@ def test_wf_draft_add_step_help_explains_explicit_wiring() -> None:
)
def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def add_step_from_capability(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.draft_add.load_cli_context", lambda _ctx: context
)
result = runner.invoke(
app,
[
"draft",
"add",
"capability",
"workspace",
"--revision",
"1",
"--step",
"call",
"--capability",
"demo.call",
"--from-step",
"start",
"--route",
"ok=__end__",
"--input",
"input.text=text",
"--bind-output",
"value=state.value",
],
)
assert result.exit_code == 0, result.output
call = calls[0]
assert call["workspace_id"] == "workspace"
assert call["revision"] == 1
assert call["step_id"] == "call"
assert call["capability_name"] == "demo.call"
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["bind_outputs"] == {"value": "state.value"}
def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
result = runner.invoke(app, ["draft", "--help"])
@@ -221,7 +318,8 @@ def test_wf_draft_route_flags_reject_duplicate_outcomes() -> None:
app,
[
"draft",
"add-step",
"add",
"capability",
"ws",
"--revision",
"1",
+21 -7
View File
@@ -15,6 +15,7 @@ from wf_cli.context import CliContext, load_cli_context, load_local_cli_context
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
from wf_transport_rpc_http.client.base import RpcClientTransport
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
from .conftest import write_python_source_config
@@ -1324,11 +1325,19 @@ def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
]
def test_wf_draft_add_step_from_capability_uses_rpc_target(
monkeypatch, tmp_path
) -> None:
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)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
@@ -1354,7 +1363,8 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
[
*base_args,
"draft",
"add-step",
"add",
"capability",
"add_step_ws",
"--revision",
"1",
@@ -1379,9 +1389,11 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
payload = json.loads(result.output)
assert payload["revision"] == 2
assert payload["status"] == "valid"
assert rpc_methods[-1] == "workflow.draft_workspaces.add_step_from_capability"
assert "workflow.draft_workspaces.add_step" not in rpc_methods
def test_wf_draft_add_step_reports_bare_output_target_without_traceback(
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
@@ -1411,7 +1423,8 @@ def test_wf_draft_add_step_reports_bare_output_target_without_traceback(
[
*base_args,
"draft",
"add-step",
"add",
"capability",
"add_step_ws",
"--revision",
"1",
@@ -1576,7 +1589,8 @@ def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
[
*base_args,
"draft",
"add-step",
"add",
"capability",
"fwd_ws",
"--revision",
"1",