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 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.context import load_cli_context_from_typer as load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload 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.io import CliInputError, emit_json, parse_bindings, parse_json_value
from wf_cli.remote_errors import run_cli_operation 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( app = typer.Typer(
name="draft", name="draft",
help="Create, inspect, patch, validate, and save draft workflows.", help="Create, inspect, patch, validate, and save draft workflows.",
no_args_is_help=True, no_args_is_help=True,
) )
app.add_typer(draft_add.app, name="add")
@app.command("list") @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") @app.command("branch")
def branch_draft( def branch_draft(
ctx: typer.Context, ctx: typer.Context,
+101 -3
View File
@@ -1,12 +1,36 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
import typer
from typer.testing import CliRunner from typer.testing import CliRunner
from wf_cli.app import app from wf_cli.app import app
from wf_cli.commands.draft_options import parse_json_file, route_source
runner = CliRunner() 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: def test_wf_help_lists_lifecycle_groups() -> None:
result = runner.invoke(app, ["--help"]) 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 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: def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
input_result = runner.invoke(app, ["draft", "set-input", "--help"]) input_result = runner.invoke(app, ["draft", "set-input", "--help"])
output_result = runner.invoke(app, ["draft", "set-output", "--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 assert "set-input --merge" in help_text
def test_wf_draft_add_step_help_explains_explicit_wiring() -> None: def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
result = runner.invoke(app, ["draft", "add-step", "--help"]) result = runner.invoke(app, ["draft", "add", "capability", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
output = " ".join(result.output.split()) 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 "--from-step" in output
assert "--bind-output" in output assert "--bind-output" in output
assert "does not guess" 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 "Repeat the flag" in output
assert "--input state.title=title --input state.summary=summary" in output assert "--input state.title=title --input state.summary=summary" in output
assert ( 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: def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
result = runner.invoke(app, ["draft", "--help"]) result = runner.invoke(app, ["draft", "--help"])
@@ -221,7 +318,8 @@ def test_wf_draft_route_flags_reject_duplicate_outcomes() -> None:
app, app,
[ [
"draft", "draft",
"add-step", "add",
"capability",
"ws", "ws",
"--revision", "--revision",
"1", "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_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.base import RpcClientTransport
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
from .conftest import write_python_source_config 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( def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
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)
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 = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8") config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner() runner = CliRunner()
@@ -1354,7 +1363,8 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
[ [
*base_args, *base_args,
"draft", "draft",
"add-step", "add",
"capability",
"add_step_ws", "add_step_ws",
"--revision", "--revision",
"1", "1",
@@ -1379,9 +1389,11 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
payload = json.loads(result.output) payload = json.loads(result.output)
assert payload["revision"] == 2 assert payload["revision"] == 2
assert payload["status"] == "valid" 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 monkeypatch, tmp_path
) -> None: ) -> None:
server = build_local_static_workflow_server(tmp_path / "store") 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, *base_args,
"draft", "draft",
"add-step", "add",
"capability",
"add_step_ws", "add_step_ws",
"--revision", "--revision",
"1", "1",
@@ -1576,7 +1589,8 @@ def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
[ [
*base_args, *base_args,
"draft", "draft",
"add-step", "add",
"capability",
"fwd_ws", "fwd_ws",
"--revision", "--revision",
"1", "1",