feat: add capability step update cli
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
@@ -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]] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user