feat: group draft add commands
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user