feat: add capability step update cli

This commit is contained in:
lda
2026-07-27 02:06:52 +07:00 Verified
parent 8d731444eb
commit bf8be6b3dc
7 changed files with 689 additions and 17 deletions
@@ -692,7 +692,7 @@ git commit -m "feat: expose capability step updates to mcp"
- Produces: `wf draft update capability`. - Produces: `wf draft update capability`.
- Extends: `wf draft add capability`. - Extends: `wf draft add capability`.
- [ ] **Step 1: Write failing parser tests** - [x] **Step 1: Write failing parser tests**
Refactor the private input-shaped path parser so the option name and target Refactor the private input-shaped path parser so the option name and target
audience are parameters: audience are parameters:
@@ -722,7 +722,7 @@ def parse_capability_input_binding_flags(
Reuse `parse_step_input_value_flags` and `parse_step_input_bindings_file`. Reuse `parse_step_input_value_flags` and `parse_step_input_bindings_file`.
Write tests for `--input`-specific diagnostics and canonical order. Write tests for `--input`-specific diagnostics and canonical order.
- [ ] **Step 2: Write failing update command tests** - [x] **Step 2: Write failing update command tests**
Add local CLI tests for: Add local CLI tests for:
@@ -747,7 +747,7 @@ and whose input bindings preserve path-then-literal order.
Add tests for every set/clear conflict, bindings-file conflict, clear-input Add tests for every set/clear conflict, bindings-file conflict, clear-input
conflict, empty update, and validation before `load_cli_context`. conflict, empty update, and validation before `load_cli_context`.
- [ ] **Step 3: Implement the update command group** - [x] **Step 3: Implement the update command group**
Create `draft_update.py` with a Typer group and `capability` command. Build a Create `draft_update.py` with a Typer group and `capability` command. Build a
plain `payload` dictionary only from selected flags: plain `payload` dictionary only from selected flags:
@@ -783,7 +783,7 @@ app.add_typer(draft_update.app, name="update")
in `drafts.py`. in `drafts.py`.
- [ ] **Step 4: Write failing add-command parity tests** - [x] **Step 4: Write failing add-command parity tests**
Test `wf draft add capability` with metadata, `--input`, `--value`, and Test `wf draft add capability` with metadata, `--input`, `--value`, and
`--bindings-file`. Pin: `--bindings-file`. Pin:
@@ -794,14 +794,14 @@ Test `wf draft add capability` with metadata, `--input`, `--value`, and
- file/convenience exclusivity before context; - file/convenience exclusivity before context;
- existing path-only invocation unchanged. - existing path-only invocation unchanged.
- [ ] **Step 5: Extend add capability** - [x] **Step 5: Extend add capability**
Add the approved flags and call canonical `input_bindings`. Do not pass both Add the approved flags and call canonical `input_bindings`. Do not pass both
the compatibility map and canonical list. The CLI should use canonical the compatibility map and canonical list. The CLI should use canonical
bindings for every new invocation; `input_map` remains only for non-CLI bindings for every new invocation; `input_map` remains only for non-CLI
compatibility callers. compatibility callers.
- [ ] **Step 6: Add remote parity tests** - [x] **Step 6: Add remote parity tests**
Use the real ASGI RPC target to assert exact methods and payloads: Use the real ASGI RPC target to assert exact methods and payloads:
@@ -813,7 +813,7 @@ workflow.draft_workspaces.add_step_from_capability
For update, assert omitted metadata keys do not appear and explicit clears do. For update, assert omitted metadata keys do not appear and explicit clears do.
For add, assert ordered path/value input bindings and metadata. For add, assert ordered path/value input bindings and metadata.
- [ ] **Step 7: Verify and commit Task 4** - [x] **Step 7: Verify and commit Task 4**
Run: Run:
+45 -2
View File
@@ -40,7 +40,10 @@ from .draft_options import (
_parse_output_map_flags, _parse_output_map_flags,
_parse_route_flags, _parse_route_flags,
_parse_step_input_map_flags, _parse_step_input_map_flags,
parse_capability_input_binding_flags,
parse_json_file, parse_json_file,
parse_step_input_bindings_file,
parse_step_input_value_flags,
route_source, route_source,
validation_error_as_bad_parameter, validation_error_as_bad_parameter,
) )
@@ -123,6 +126,20 @@ def add_step_from_capability(
), ),
), ),
] = None, ] = None,
input_value: Annotated[
list[str] | None,
typer.Option(
"--value",
help="Literal input binding LOCAL_TARGET=JSON. Repeat as needed.",
),
] = None,
bindings_file: Annotated[
Path | None,
typer.Option(
"--bindings-file",
help="Ordered canonical JSON input-binding list.",
),
] = None,
output_mapping: Annotated[ output_mapping: Annotated[
list[str] | None, list[str] | None,
typer.Option( typer.Option(
@@ -134,6 +151,16 @@ def add_step_from_capability(
), ),
), ),
] = None, ] = None,
description: Annotated[
str | None, typer.Option("--description", help="Step description.")
] = None,
retry: Annotated[
int | None, typer.Option("--retry", min=0, help="Retry count.")
] = None,
timeout_seconds: Annotated[
int | None,
typer.Option("--timeout-seconds", min=1, help="Timeout in seconds."),
] = None,
) -> None: ) -> None:
"""Add a capability step; this also projects its schemas and bindings. """Add a capability step; this also projects its schemas and bindings.
@@ -149,7 +176,19 @@ def add_step_from_capability(
`--input state.title=report.title --input state.summary=report.summary` `--input state.title=report.title --input state.summary=report.summary`
`--bind-output title=state.title --bind-output summary=state.summary` `--bind-output title=state.title --bind-output summary=state.summary`
""" """
input_map = _parse_step_input_map_flags(input_mapping, option_name="--input") convenience_input_selected = input_mapping is not None or input_value is not None
if bindings_file is not None and convenience_input_selected:
raise typer.BadParameter(
"--bindings-file is mutually exclusive with --input and --value"
)
input_bindings = (
parse_step_input_bindings_file(bindings_file)
if bindings_file is not None
else [
*parse_capability_input_binding_flags(input_mapping),
*parse_step_input_value_flags(input_value),
]
)
bind_outputs = _parse_output_map_flags(output_mapping) bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route) routes = _parse_route_flags(route)
context = load_cli_context(ctx) context = load_cli_context(ctx)
@@ -164,8 +203,12 @@ def add_step_from_capability(
route_from_step=route_from_step, route_from_step=route_from_step,
route_from_outcome=route_from_outcome, route_from_outcome=route_from_outcome,
routes=routes or None, routes=routes or None,
input_map=input_map, input_map=None,
input_bindings=input_bindings,
bind_outputs=bind_outputs, bind_outputs=bind_outputs,
desc=description,
retry=retry,
timeout_seconds=timeout_seconds,
), ),
) )
) )
+28 -6
View File
@@ -108,12 +108,28 @@ def parse_step_input_binding_flags(
values: list[str] | None, values: list[str] | None,
) -> list[InputPathBinding]: ) -> list[InputPathBinding]:
"""Parse ordered step-input path bindings without collapsing source fan-out.""" """Parse ordered step-input path bindings without collapsing source fan-out."""
return _parse_input_path_binding_flags(values, target_label="node-local") return _parse_input_path_binding_flags(
values,
option_name="--map",
target_label="node-local",
)
def parse_capability_input_binding_flags(
values: list[str] | None,
) -> list[InputPathBinding]:
"""Parse ordered capability input paths from the public ``--input`` flag."""
return _parse_input_path_binding_flags(
values,
option_name="--input",
target_label="node-local",
)
def _parse_input_path_binding_flags( def _parse_input_path_binding_flags(
values: list[str] | None, values: list[str] | None,
*, *,
option_name: str,
target_label: str, target_label: str,
) -> list[InputPathBinding]: ) -> list[InputPathBinding]:
"""Parse ordered GRAPH_SOURCE=LOCAL_TARGET bindings for one CLI audience.""" """Parse ordered GRAPH_SOURCE=LOCAL_TARGET bindings for one CLI audience."""
@@ -121,24 +137,26 @@ def _parse_input_path_binding_flags(
for item in values or []: for item in values or []:
source, separator, target = item.partition("=") source, separator, target = item.partition("=")
if separator != "=" or not source or not target: if separator != "=" or not source or not target:
raise typer.BadParameter("--map must use GRAPH_SOURCE=LOCAL_TARGET") raise typer.BadParameter(
f"{option_name} must use GRAPH_SOURCE=LOCAL_TARGET"
)
if target.startswith("local."): if target.startswith("local."):
bare_target = target.removeprefix("local.") bare_target = target.removeprefix("local.")
raise typer.BadParameter( raise typer.BadParameter(
f"--map target must be a rootless {target_label} path; " f"{option_name} target must be a rootless {target_label} path; "
f"use {source}={bare_target}, not {source}={target}" f"use {source}={bare_target}, not {source}={target}"
) )
try: try:
source_path = GraphSourcePath.parse(source) source_path = GraphSourcePath.parse(source)
except PathResolutionError as exc: except PathResolutionError as exc:
raise typer.BadParameter( raise typer.BadParameter(
f"--map source must be a graph source path: {exc}" f"{option_name} source must be a graph source path: {exc}"
) from exc ) from exc
try: try:
target_path = LocalPath.parse(target) target_path = LocalPath.parse(target)
except PathResolutionError as exc: except PathResolutionError as exc:
raise typer.BadParameter( raise typer.BadParameter(
f"--map target must be a rootless {target_label} path: {exc}" f"{option_name} target must be a rootless {target_label} path: {exc}"
) from exc ) from exc
bindings.append(InputPathBinding(path=source_path, target=target_path)) bindings.append(InputPathBinding(path=source_path, target=target_path))
return bindings return bindings
@@ -198,7 +216,11 @@ def parse_workflow_output_binding_flags(
values: list[str] | None, values: list[str] | None,
) -> list[InputPathBinding]: ) -> list[InputPathBinding]:
"""Parse ordered canonical workflow-output path bindings.""" """Parse ordered canonical workflow-output path bindings."""
return _parse_input_path_binding_flags(values, target_label="workflow-output") return _parse_input_path_binding_flags(
values,
option_name="--map",
target_label="workflow-output",
)
def parse_workflow_output_value_flags( def parse_workflow_output_value_flags(
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import typer
from pydantic import ValidationError
from wf_api import CapabilityStepUpdate
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_capability_input_binding_flags,
parse_step_input_bindings_file,
parse_step_input_value_flags,
validation_error_as_bad_parameter,
)
app = typer.Typer(
name="update",
help="Update one existing typed draft step.",
no_args_is_help=True,
)
def _reject_set_clear_conflict(
*,
value_is_set: bool,
clear: bool,
value_option: str,
clear_option: str,
) -> None:
if value_is_set and clear:
raise typer.BadParameter(
f"{value_option} and {clear_option} are mutually exclusive"
)
@app.command("capability")
def update_capability_step(
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="Existing draft step id.")],
description: Annotated[
str | None, typer.Option("--description", help="Replace the step description.")
] = None,
clear_description: Annotated[
bool,
typer.Option("--clear-description", help="Remove the step description."),
] = False,
retry: Annotated[
int | None, typer.Option("--retry", min=0, help="Replace the retry count.")
] = None,
clear_retry: Annotated[
bool, typer.Option("--clear-retry", help="Remove the retry override.")
] = False,
timeout_seconds: Annotated[
int | None,
typer.Option(
"--timeout-seconds",
min=1,
help="Replace the timeout in seconds.",
),
] = None,
clear_timeout: Annotated[
bool, typer.Option("--clear-timeout", help="Remove the timeout override.")
] = False,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help="Input binding GRAPH_SOURCE=LOCAL_TARGET. Repeat as needed.",
),
] = None,
input_value: Annotated[
list[str] | None,
typer.Option(
"--value",
help="Literal input binding LOCAL_TARGET=JSON. Repeat as needed.",
),
] = None,
bindings_file: Annotated[
Path | None,
typer.Option(
"--bindings-file",
help="Ordered canonical JSON input-binding list.",
),
] = None,
clear_input: Annotated[
bool,
typer.Option("--clear-input", help="Replace input with no bindings."),
] = False,
) -> None:
"""Patch metadata or replace all input bindings on a capability step."""
_reject_set_clear_conflict(
value_is_set=description is not None,
clear=clear_description,
value_option="--description",
clear_option="--clear-description",
)
_reject_set_clear_conflict(
value_is_set=retry is not None,
clear=clear_retry,
value_option="--retry",
clear_option="--clear-retry",
)
_reject_set_clear_conflict(
value_is_set=timeout_seconds is not None,
clear=clear_timeout,
value_option="--timeout-seconds",
clear_option="--clear-timeout",
)
convenience_input_selected = input_mapping is not None or input_value is not None
if bindings_file is not None and (convenience_input_selected or clear_input):
raise typer.BadParameter(
"--bindings-file is mutually exclusive with --input, --value, "
"and --clear-input"
)
if clear_input and convenience_input_selected:
raise typer.BadParameter(
"--clear-input is mutually exclusive with --input and --value"
)
payload: dict[str, object] = {}
if description is not None:
payload["desc"] = description
elif clear_description:
payload["desc"] = None
if retry is not None:
payload["retry"] = retry
elif clear_retry:
payload["retry"] = None
if timeout_seconds is not None:
payload["timeout_seconds"] = timeout_seconds
elif clear_timeout:
payload["timeout_seconds"] = None
if bindings_file is not None:
payload["input"] = parse_step_input_bindings_file(bindings_file)
elif clear_input:
payload["input"] = []
elif convenience_input_selected:
payload["input"] = [
*parse_capability_input_binding_flags(input_mapping),
*parse_step_input_value_flags(input_value),
]
try:
update = CapabilityStepUpdate.model_validate(payload)
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.update_capability_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
update=update,
),
)
)
+2 -1
View File
@@ -6,7 +6,7 @@ from typing import Annotated, Literal
import typer import typer
from wf_cli.commands import draft_add from wf_cli.commands import draft_add, draft_update
from wf_cli.commands.draft_options import ( from wf_cli.commands.draft_options import (
_parse_map_flags, _parse_map_flags,
_parse_output_map_flags, _parse_output_map_flags,
@@ -33,6 +33,7 @@ app = typer.Typer(
no_args_is_help=True, no_args_is_help=True,
) )
app.add_typer(draft_add.app, name="add") app.add_typer(draft_add.app, name="add")
app.add_typer(draft_update.app, name="update")
def _validate_outcomes(values: list[str] | None) -> tuple[str, ...] | None: def _validate_outcomes(values: list[str] | None) -> tuple[str, ...] | None:
+335 -1
View File
@@ -10,6 +10,7 @@ from typer.testing import CliRunner
from wf_cli.app import app from wf_cli.app import app
from wf_cli.commands.draft_options import ( from wf_cli.commands.draft_options import (
parse_capability_input_binding_flags,
parse_json_file, parse_json_file,
parse_step_input_binding_flags, parse_step_input_binding_flags,
parse_step_input_bindings_file, parse_step_input_bindings_file,
@@ -120,6 +121,22 @@ def test_draft_options_parse_step_input_bindings_preserves_source_fan_out() -> N
] ]
def test_draft_options_parse_capability_inputs_preserves_order() -> None:
bindings = parse_capability_input_binding_flags(
["state.title=request.title", "state.title=audit.title"]
)
assert [binding.model_dump(mode="json") for binding in bindings] == [
{"path": "state.title", "target": "request.title"},
{"path": "state.title", "target": "audit.title"},
]
def test_draft_options_parse_capability_inputs_names_input_option() -> None:
with pytest.raises(typer.BadParameter, match="--input must use"):
parse_capability_input_binding_flags(["not-an-assignment"])
def test_draft_options_parse_step_input_values_preserves_null_and_equals() -> None: def test_draft_options_parse_step_input_values_preserves_null_and_equals() -> None:
bindings = parse_step_input_value_flags( bindings = parse_step_input_value_flags(
['request.format="markdown=compact"', "request.optional=null"] ['request.format="markdown=compact"', "request.optional=null"]
@@ -782,6 +799,11 @@ def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
assert "--capability" in output assert "--capability" in output
assert "--from-step" in output assert "--from-step" in output
assert "--bind-output" in output assert "--bind-output" in output
assert "--description" in output
assert "--retry" in output
assert "--timeout-seconds" in output
assert "--value" in output
assert "--bindings-file" in output
assert "does not guess" in output assert "does not guess" in output
assert "projects its schemas and bindings" in output assert "projects its schemas and bindings" in output
assert "draft validate" in output assert "draft validate" in output
@@ -793,6 +815,26 @@ def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
) )
def test_wf_draft_update_capability_help_lists_patch_controls() -> None:
result = runner.invoke(app, ["draft", "update", "capability", "--help"])
assert result.exit_code == 0
output = " ".join(result.output.split())
for option in (
"--description",
"--clear-description",
"--retry",
"--clear-retry",
"--timeout-seconds",
"--clear-timeout",
"--input",
"--value",
"--bindings-file",
"--clear-input",
):
assert option in output
def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> None: def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> None:
calls: list[dict[str, Any]] = [] calls: list[dict[str, Any]] = []
@@ -839,10 +881,302 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
assert call["route_from_step"] == "start" assert call["route_from_step"] == "start"
assert call["route_from_outcome"] == "ok" assert call["route_from_outcome"] == "ok"
assert call["routes"] == {"ok": "__end__"} assert call["routes"] == {"ok": "__end__"}
assert call["input_map"] == {"input.text": "report.text"} assert call["input_map"] is None
assert [binding.model_dump(mode="json") for binding in call["input_bindings"]] == [
{"path": "input.text", "target": "report.text"}
]
assert call["bind_outputs"] == {"value": "state.value"} assert call["bind_outputs"] == {"value": "state.value"}
def test_wf_draft_update_capability_builds_presence_aware_patch(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def update_capability_step(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 5, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr(
"wf_cli.commands.draft_update.load_cli_context", lambda _ctx: context
)
result = runner.invoke(
app,
[
"draft",
"update",
"capability",
"report",
"--revision",
"4",
"--step",
"publish",
"--description",
"Publish report",
"--retry",
"0",
"--clear-timeout",
"--input",
"state.report.title=request.title",
"--value",
'request.format="markdown"',
],
)
assert result.exit_code == 0, result.output
call = calls[0]
assert call["workspace_id"] == "report"
assert call["revision"] == 4
assert call["step_id"] == "publish"
update = call["update"]
assert update.model_fields_set == {
"desc",
"retry",
"timeout_seconds",
"input",
}
assert update.desc == "Publish report"
assert update.retry == 0
assert update.timeout_seconds is None
assert [binding.model_dump(mode="json") for binding in update.input] == [
{"path": "state.report.title", "target": "request.title"},
{"value": "markdown", "target": "request.format"},
]
@pytest.mark.parametrize(
"args",
[
["--description", "new", "--clear-description"],
["--retry", "1", "--clear-retry"],
["--timeout-seconds", "5", "--clear-timeout"],
["--bindings-file", "bindings.json", "--input", "state.x=x"],
["--bindings-file", "bindings.json", "--value", "x=1"],
["--bindings-file", "bindings.json", "--clear-input"],
["--clear-input", "--input", "state.x=x"],
["--clear-input", "--value", "x=1"],
[],
],
)
def test_wf_draft_update_capability_rejects_invalid_modes_before_context(
monkeypatch, args: list[str]
) -> None:
monkeypatch.setattr(
"wf_cli.commands.draft_update.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"update",
"capability",
"report",
"--revision",
"4",
"--step",
"publish",
*args,
],
)
assert result.exit_code != 0
assert "context loaded" not in result.output
def test_wf_draft_update_capability_loads_exact_bindings_file(
monkeypatch, tmp_path
) -> None:
calls: list[dict[str, Any]] = []
bindings_path = tmp_path / "bindings.json"
bindings_path.write_text(
json.dumps(
[
{"value": "markdown", "target": "request.format"},
{"path": "state.title", "target": "request.title"},
]
),
encoding="utf-8",
)
class FakeHandlers:
async def update_capability_step(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 5, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr(
"wf_cli.commands.draft_update.load_cli_context", lambda _ctx: context
)
result = runner.invoke(
app,
[
"draft",
"update",
"capability",
"report",
"--revision",
"4",
"--step",
"publish",
"--bindings-file",
str(bindings_path),
],
)
assert result.exit_code == 0, result.output
update = calls[0]["update"]
assert update.model_fields_set == {"input"}
assert [binding.model_dump(mode="json") for binding in update.input] == [
{"value": "markdown", "target": "request.format"},
{"path": "state.title", "target": "request.title"},
]
def test_wf_draft_add_capability_accepts_canonical_input_and_metadata(
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",
"--description",
"Call demo",
"--retry",
"0",
"--timeout-seconds",
"15",
"--input",
"state.title=request.title",
"--value",
'request.format="markdown"',
],
)
assert result.exit_code == 0, result.output
call = calls[0]
assert call["input_map"] is None
assert [binding.model_dump(mode="json") for binding in call["input_bindings"]] == [
{"path": "state.title", "target": "request.title"},
{"value": "markdown", "target": "request.format"},
]
assert call["desc"] == "Call demo"
assert call["retry"] == 0
assert call["timeout_seconds"] == 15
def test_wf_draft_add_capability_preserves_bindings_file_order(
monkeypatch, tmp_path
) -> None:
calls: list[dict[str, Any]] = []
bindings_path = tmp_path / "bindings.json"
bindings_path.write_text(
json.dumps(
[
{"value": 1, "target": "request.first"},
{"path": "state.second", "target": "request.second"},
]
),
encoding="utf-8",
)
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",
"--bindings-file",
str(bindings_path),
],
)
assert result.exit_code == 0, result.output
assert [
binding.model_dump(mode="json") for binding in calls[0]["input_bindings"]
] == [
{"value": 1, "target": "request.first"},
{"path": "state.second", "target": "request.second"},
]
@pytest.mark.parametrize(
"extra_args",
[
["--bindings-file", "bindings.json", "--input", "state.x=x"],
["--bindings-file", "bindings.json", "--value", "x=1"],
],
)
def test_wf_draft_add_capability_rejects_binding_modes_before_context(
monkeypatch, extra_args: list[str]
) -> None:
monkeypatch.setattr(
"wf_cli.commands.draft_add.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"add",
"capability",
"workspace",
"--revision",
"1",
"--step",
"call",
"--capability",
"demo.call",
*extra_args,
],
)
assert result.exit_code != 0
assert "context loaded" not in result.output
def test_wf_draft_add_interrupt_builds_typed_contract(monkeypatch, tmp_path) -> None: def test_wf_draft_add_interrupt_builds_typed_contract(monkeypatch, tmp_path) -> None:
calls: list[dict[str, Any]] = [] calls: list[dict[str, Any]] = []
+102
View File
@@ -1799,6 +1799,108 @@ def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
assert "workflow.draft_workspaces.add_step" not in rpc_methods assert "workflow.draft_workspaces.add_step" not in rpc_methods
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
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()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"capability_update_ws",
"--capability",
"wf.std.constant",
"--name",
"capability_update",
],
)
assert created.exit_code == 0, created.output
added = runner.invoke(
app,
[
*base_args,
"draft",
"add",
"capability",
"capability_update_ws",
"--revision",
"1",
"--step",
"join",
"--capability",
"wf.std.concat",
"--from-step",
"call",
"--input",
"input.value=items",
"--value",
'separator=","',
"--description",
"Join values",
"--retry",
"0",
"--timeout-seconds",
"5",
],
)
assert added.exit_code == 0, added.output
add_method, add_params = rpc_calls[-1]
assert add_method == "workflow.draft_workspaces.add_step_from_capability"
assert add_params["input_bindings"] == [
{"path": "input.value", "target": "items"},
{"value": ",", "target": "separator"},
]
assert "input_map" not in add_params
assert add_params["desc"] == "Join values"
assert add_params["retry"] == 0
assert add_params["timeout_seconds"] == 5
updated = runner.invoke(
app,
[
*base_args,
"draft",
"update",
"capability",
"capability_update_ws",
"--revision",
"2",
"--step",
"join",
"--clear-description",
"--clear-timeout",
],
)
assert updated.exit_code == 0, updated.output
update_method, update_params = rpc_calls[-1]
assert update_method == "workflow.draft_workspaces.update_capability_step"
assert update_params["update"] == {
"desc": None,
"timeout_seconds": None,
}
def test_wf_draft_add_control_steps_use_generic_rpc_target( def test_wf_draft_add_control_steps_use_generic_rpc_target(
monkeypatch, tmp_path monkeypatch, tmp_path
) -> None: ) -> None: