feat: add draft control step commands
This commit is contained in:
@@ -1,14 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_artifacts.drafts.models import (
|
||||
DraftEndPayload,
|
||||
DraftEndStep,
|
||||
DraftForeachPayload,
|
||||
DraftForeachStep,
|
||||
DraftInterruptPayload,
|
||||
DraftInterruptStep,
|
||||
DraftJoinStep,
|
||||
DraftStep,
|
||||
)
|
||||
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 wf_core.models.schemas import SchemaRef
|
||||
from wf_core.models.steps import (
|
||||
ForeachConcurrentPolicy,
|
||||
)
|
||||
|
||||
from .draft_options import _parse_map_flags, _parse_output_map_flags, _parse_route_flags
|
||||
from .draft_options import (
|
||||
_parse_map_flags,
|
||||
_parse_output_map_flags,
|
||||
_parse_route_flags,
|
||||
_parse_step_input_map_flags,
|
||||
parse_json_file,
|
||||
route_source,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="add",
|
||||
@@ -17,6 +40,39 @@ app = typer.Typer(
|
||||
)
|
||||
|
||||
|
||||
def _submit_step(
|
||||
ctx: typer.Context,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
step: DraftStep,
|
||||
from_step: str | None,
|
||||
from_outcome: str | None,
|
||||
routes: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Send every typed step through the same local-or-remote API boundary."""
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
run_cli_operation(
|
||||
context,
|
||||
context.handlers.add_step(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=step,
|
||||
incoming=route_source(from_step, from_outcome),
|
||||
routes=routes,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _as_bad_parameter(exc: ValidationError) -> typer.BadParameter:
|
||||
"""Keep model validation failures on Click's concise input-error surface."""
|
||||
return typer.BadParameter(str(exc))
|
||||
|
||||
|
||||
@app.command("capability")
|
||||
def add_step_from_capability(
|
||||
ctx: typer.Context,
|
||||
@@ -99,13 +155,276 @@ def add_step_from_capability(
|
||||
)
|
||||
|
||||
|
||||
# These subgroups establish the public command boundary for later task slices.
|
||||
# They intentionally have no command bodies until their typed options are ready.
|
||||
@app.command("interrupt")
|
||||
def add_interrupt_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="New draft step id.")],
|
||||
kind: Annotated[str, typer.Option("--kind", help="Interrupt request kind.")],
|
||||
from_step: Annotated[
|
||||
str | None, typer.Option("--from-step", help="Incoming step id.")
|
||||
] = None,
|
||||
from_outcome: Annotated[
|
||||
str | None,
|
||||
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
|
||||
] = None,
|
||||
request_schema_file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--request-schema-file", help="JSON Schema for the interrupt request."
|
||||
),
|
||||
] = None,
|
||||
resume_schema_file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--resume-schema-file", help="JSON Schema for the resume payload."
|
||||
),
|
||||
] = None,
|
||||
request: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--request",
|
||||
help="Request binding GRAPH_SOURCE=LOCAL_TARGET. Repeat as needed.",
|
||||
),
|
||||
] = None,
|
||||
resume: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--resume",
|
||||
help="Resume binding LOCAL_SOURCE=STATE_TARGET. Repeat as needed.",
|
||||
),
|
||||
] = None,
|
||||
outcome: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option("--outcome", help="Declared resume outcome. Repeat as needed."),
|
||||
] = None,
|
||||
route: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Add a typed interrupt and its request/resume contract."""
|
||||
request_map = _parse_step_input_map_flags(request, option_name="--request")
|
||||
resume_map = _parse_output_map_flags(resume, option_name="--resume")
|
||||
routes = _parse_route_flags(route)
|
||||
try:
|
||||
request_schema = (
|
||||
SchemaRef.model_validate(
|
||||
parse_json_file(
|
||||
request_schema_file, option_name="--request-schema-file"
|
||||
)
|
||||
)
|
||||
if request_schema_file is not None
|
||||
else None
|
||||
)
|
||||
resume_schema = (
|
||||
SchemaRef.model_validate(
|
||||
parse_json_file(resume_schema_file, option_name="--resume-schema-file")
|
||||
)
|
||||
if resume_schema_file is not None
|
||||
else None
|
||||
)
|
||||
step = DraftInterruptStep(
|
||||
interrupt=DraftInterruptPayload.model_validate(
|
||||
{
|
||||
"kind": kind,
|
||||
"request_schema": request_schema,
|
||||
"resume_schema": resume_schema,
|
||||
"request": [
|
||||
{"path": source, "target": target}
|
||||
for source, target in request_map.items()
|
||||
],
|
||||
"resume": [
|
||||
{"source": source, "target": target}
|
||||
for source, target in resume_map.items()
|
||||
],
|
||||
"outcomes": outcome or ["submitted"],
|
||||
}
|
||||
)
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise _as_bad_parameter(exc) from exc
|
||||
|
||||
_submit_step(
|
||||
ctx,
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=step,
|
||||
from_step=from_step,
|
||||
from_outcome=from_outcome,
|
||||
routes=routes or None,
|
||||
)
|
||||
|
||||
|
||||
@app.command("foreach")
|
||||
def add_foreach_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="New draft step id.")],
|
||||
over: Annotated[
|
||||
str, typer.Option("--over", help="Graph path containing the item list.")
|
||||
],
|
||||
as_: Annotated[
|
||||
str, typer.Option("--as", help="Context key for the current item.")
|
||||
],
|
||||
mode: Annotated[
|
||||
Literal["serial", "concurrent"],
|
||||
typer.Option("--mode", help="Item admission mode."),
|
||||
] = "serial",
|
||||
item_error: Annotated[
|
||||
Literal["fail", "skip", "collect"],
|
||||
typer.Option("--item-error", help="Per-item failure policy."),
|
||||
] = "fail",
|
||||
collect_to: Annotated[
|
||||
str | None,
|
||||
typer.Option("--collect-to", help="State path for collected item errors."),
|
||||
] = None,
|
||||
max_active: Annotated[
|
||||
int | None,
|
||||
typer.Option("--max-active", min=1, help="Concurrent active-item limit."),
|
||||
] = None,
|
||||
max_outstanding: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--max-outstanding", min=1, help="Concurrent outstanding-item limit."
|
||||
),
|
||||
] = None,
|
||||
from_step: Annotated[
|
||||
str | None, typer.Option("--from-step", help="Incoming step id.")
|
||||
] = None,
|
||||
from_outcome: Annotated[
|
||||
str | None,
|
||||
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
|
||||
] = None,
|
||||
route: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Add a foreach loop with explicit item and concurrency policies."""
|
||||
if mode == "serial" and (max_active is not None or max_outstanding is not None):
|
||||
raise typer.BadParameter(
|
||||
"--max-active and --max-outstanding require --mode concurrent"
|
||||
)
|
||||
try:
|
||||
concurrent_options: dict[str, int] = {}
|
||||
if max_active is not None:
|
||||
concurrent_options["max_active"] = max_active
|
||||
if max_outstanding is not None:
|
||||
concurrent_options["max_outstanding"] = max_outstanding
|
||||
concurrent = (
|
||||
ForeachConcurrentPolicy.model_validate(concurrent_options)
|
||||
if mode == "concurrent"
|
||||
else None
|
||||
)
|
||||
step = DraftForeachStep(
|
||||
foreach=DraftForeachPayload.model_validate(
|
||||
{
|
||||
"over": over,
|
||||
"as": as_,
|
||||
"mode": mode,
|
||||
"item_error": {
|
||||
"action": item_error,
|
||||
"collect_to": collect_to,
|
||||
},
|
||||
"concurrent": concurrent,
|
||||
}
|
||||
)
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise _as_bad_parameter(exc) from exc
|
||||
|
||||
_submit_step(
|
||||
ctx,
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=step,
|
||||
from_step=from_step,
|
||||
from_outcome=from_outcome,
|
||||
routes=_parse_route_flags(route) or None,
|
||||
)
|
||||
|
||||
|
||||
@app.command("join")
|
||||
def add_join_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="New draft step id.")],
|
||||
from_step: Annotated[
|
||||
str | None, typer.Option("--from-step", help="Incoming step id.")
|
||||
] = None,
|
||||
from_outcome: Annotated[
|
||||
str | None,
|
||||
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
|
||||
] = None,
|
||||
route: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Add a join step."""
|
||||
_submit_step(
|
||||
ctx,
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=DraftJoinStep(join={}),
|
||||
from_step=from_step,
|
||||
from_outcome=from_outcome,
|
||||
routes=_parse_route_flags(route) or None,
|
||||
)
|
||||
|
||||
|
||||
@app.command("end")
|
||||
def add_end_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="New draft step id.")],
|
||||
outcome: Annotated[
|
||||
str, typer.Option("--outcome", help="Public workflow outcome.")
|
||||
] = "ok",
|
||||
from_step: Annotated[
|
||||
str | None, typer.Option("--from-step", help="Incoming step id.")
|
||||
] = None,
|
||||
from_outcome: Annotated[
|
||||
str | None,
|
||||
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Add an explicit terminal outcome step."""
|
||||
try:
|
||||
step = DraftEndStep(end=DraftEndPayload(outcome=outcome))
|
||||
except ValidationError as exc:
|
||||
raise _as_bad_parameter(exc) from exc
|
||||
_submit_step(
|
||||
ctx,
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=step,
|
||||
from_step=from_step,
|
||||
from_outcome=from_outcome,
|
||||
routes=None,
|
||||
)
|
||||
|
||||
|
||||
# These subgroups establish the public boundary for the next command slice.
|
||||
# They intentionally have no bodies until their typed decision options are ready.
|
||||
for _name in (
|
||||
"interrupt",
|
||||
"foreach",
|
||||
"join",
|
||||
"end",
|
||||
"when",
|
||||
"choose",
|
||||
"match",
|
||||
|
||||
@@ -35,10 +35,12 @@ def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
|
||||
)
|
||||
|
||||
|
||||
def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
|
||||
def _parse_output_map_flags(
|
||||
values: list[str] | None, *, option_name: str = "--bind-output"
|
||||
) -> dict[str, str]:
|
||||
parsed = _parse_assignment_flags(
|
||||
values,
|
||||
option_name="--bind-output",
|
||||
option_name=option_name,
|
||||
expected="LOCAL_OUTPUT=STATE_TARGET",
|
||||
)
|
||||
for local_output, state_target in parsed.items():
|
||||
@@ -46,27 +48,33 @@ def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
|
||||
LocalPath.parse(local_output)
|
||||
except PathResolutionError as exc:
|
||||
raise typer.BadParameter(
|
||||
f"--bind-output source {local_output!r} must be a node-local "
|
||||
f"{option_name} 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 "
|
||||
f"{option_name} 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]:
|
||||
def _parse_step_input_map_flags(
|
||||
values: list[str] | None, *, option_name: str = "--map"
|
||||
) -> dict[str, str]:
|
||||
"""Parse graph-source to bare-local input mappings for one draft step."""
|
||||
parsed = _parse_map_flags(values)
|
||||
parsed = _parse_assignment_flags(
|
||||
values,
|
||||
option_name=option_name,
|
||||
expected="GRAPH_SOURCE=LOCAL_TARGET",
|
||||
)
|
||||
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"{option_name} target must be a bare local field; "
|
||||
f"use {source}={bare_target}, not {source}={target}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
@@ -290,6 +290,417 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
|
||||
assert call["bind_outputs"] == {"value": "state.value"}
|
||||
|
||||
|
||||
def test_wf_draft_add_interrupt_builds_typed_contract(monkeypatch, tmp_path) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeHandlers:
|
||||
async def add_step(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(kwargs)
|
||||
return {"revision": 2, "status": "valid"}
|
||||
|
||||
request_schema = tmp_path / "request.json"
|
||||
request_schema.write_text(
|
||||
'{"type":"object","properties":{"issues":{"type":"array"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
resume_schema = tmp_path / "resume.json"
|
||||
resume_schema.write_text(
|
||||
'{"type":"object","properties":{"selected":{"type":"array"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
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",
|
||||
"interrupt",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"review",
|
||||
"--kind",
|
||||
"issue_review",
|
||||
"--from-step",
|
||||
"draft_issues",
|
||||
"--request-schema-file",
|
||||
str(request_schema),
|
||||
"--resume-schema-file",
|
||||
str(resume_schema),
|
||||
"--request",
|
||||
"state.issues=issues",
|
||||
"--resume",
|
||||
"selected=state.selected",
|
||||
"--outcome",
|
||||
"submitted",
|
||||
"--outcome",
|
||||
"cancelled",
|
||||
"--route",
|
||||
"submitted=create_issues",
|
||||
"--route",
|
||||
"cancelled=revise",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
call = calls[0]
|
||||
assert call["workspace_id"] == "workspace"
|
||||
assert call["incoming"].step_id == "draft_issues"
|
||||
assert call["incoming"].outcome == "ok"
|
||||
assert call["routes"] == {
|
||||
"submitted": "create_issues",
|
||||
"cancelled": "revise",
|
||||
}
|
||||
assert call["step"].model_dump(mode="json", by_alias=True) == {
|
||||
"interrupt": {
|
||||
"kind": "issue_review",
|
||||
"request": [{"target": "issues", "path": "state.issues"}],
|
||||
"resume": [{"source": "selected", "target": "state.selected"}],
|
||||
"request_schema": {
|
||||
"type": "object",
|
||||
"properties": {"issues": {"type": "array"}},
|
||||
"required": [],
|
||||
},
|
||||
"resume_schema": {
|
||||
"type": "object",
|
||||
"properties": {"selected": {"type": "array"}},
|
||||
"required": [],
|
||||
},
|
||||
"outcomes": ["submitted", "cancelled"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_wf_draft_add_foreach_builds_concurrent_policy(monkeypatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeHandlers:
|
||||
async def add_step(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",
|
||||
"foreach",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"each_issue",
|
||||
"--over",
|
||||
"state.issues",
|
||||
"--as",
|
||||
"issue",
|
||||
"--mode",
|
||||
"concurrent",
|
||||
"--item-error",
|
||||
"collect",
|
||||
"--collect-to",
|
||||
"state.errors",
|
||||
"--max-active",
|
||||
"2",
|
||||
"--max-outstanding",
|
||||
"5",
|
||||
"--route",
|
||||
"loop=process_issue",
|
||||
"--route",
|
||||
"done=finish",
|
||||
"--route",
|
||||
"completed_with_errors=finish",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
call = calls[0]
|
||||
assert call["step"].model_dump(mode="json", by_alias=True) == {
|
||||
"foreach": {
|
||||
"over": "state.issues",
|
||||
"as": "issue",
|
||||
"mode": "concurrent",
|
||||
"item_error": {"action": "collect", "collect_to": "state.errors"},
|
||||
"concurrent": {
|
||||
"max_active": 2,
|
||||
"max_outstanding": 5,
|
||||
"interrupt": "quiesce",
|
||||
},
|
||||
}
|
||||
}
|
||||
assert call["routes"] == {
|
||||
"loop": "process_issue",
|
||||
"done": "finish",
|
||||
"completed_with_errors": "finish",
|
||||
}
|
||||
|
||||
defaults_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"foreach",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"2",
|
||||
"--step",
|
||||
"each_default",
|
||||
"--over",
|
||||
"state.issues",
|
||||
"--as",
|
||||
"issue",
|
||||
"--mode",
|
||||
"concurrent",
|
||||
],
|
||||
)
|
||||
assert defaults_result.exit_code == 0, defaults_result.output
|
||||
assert calls[1]["step"].foreach.concurrent is not None
|
||||
assert calls[1]["step"].foreach.concurrent.max_active == 4
|
||||
assert calls[1]["step"].foreach.concurrent.max_outstanding == 20
|
||||
|
||||
|
||||
def test_wf_draft_add_join_and_end_build_concrete_steps(monkeypatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeHandlers:
|
||||
async def add_step(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(kwargs)
|
||||
return {"revision": len(calls) + 1, "status": "valid"}
|
||||
|
||||
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||
monkeypatch.setattr(
|
||||
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
|
||||
)
|
||||
|
||||
join_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"join",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"joined",
|
||||
"--from-step",
|
||||
"each_issue",
|
||||
"--from-outcome",
|
||||
"done",
|
||||
"--route",
|
||||
"done=finish",
|
||||
],
|
||||
)
|
||||
end_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"end",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"2",
|
||||
"--step",
|
||||
"finish",
|
||||
"--outcome",
|
||||
"completed",
|
||||
],
|
||||
)
|
||||
|
||||
assert join_result.exit_code == 0, join_result.output
|
||||
assert end_result.exit_code == 0, end_result.output
|
||||
assert calls[0]["step"].model_dump(mode="json") == {"join": {}}
|
||||
assert calls[0]["routes"] == {"done": "finish"}
|
||||
assert calls[1]["step"].model_dump(mode="json") == {
|
||||
"end": {"outcome": "completed"}
|
||||
}
|
||||
assert calls[1]["routes"] is None
|
||||
|
||||
|
||||
def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeHandlers:
|
||||
async def add_step(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(kwargs)
|
||||
return {}
|
||||
|
||||
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||
monkeypatch.setattr(
|
||||
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
|
||||
)
|
||||
malformed_schema = tmp_path / "bad.json"
|
||||
malformed_schema.write_text("{", encoding="utf-8")
|
||||
|
||||
malformed = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"interrupt",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"review",
|
||||
"--kind",
|
||||
"review",
|
||||
"--request-schema-file",
|
||||
str(malformed_schema),
|
||||
],
|
||||
)
|
||||
serial_limits = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"foreach",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"each",
|
||||
"--over",
|
||||
"state.items",
|
||||
"--as",
|
||||
"item",
|
||||
"--max-active",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
duplicate_request = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"interrupt",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"review",
|
||||
"--kind",
|
||||
"review",
|
||||
"--request",
|
||||
"state.items=items",
|
||||
"--request",
|
||||
"state.items=other_items",
|
||||
],
|
||||
)
|
||||
duplicate_resume = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"interrupt",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"review",
|
||||
"--kind",
|
||||
"review",
|
||||
"--resume",
|
||||
"decision=state.decision",
|
||||
"--resume",
|
||||
"decision=state.other_decision",
|
||||
],
|
||||
)
|
||||
missing_collect_target = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"foreach",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"each",
|
||||
"--over",
|
||||
"state.items",
|
||||
"--as",
|
||||
"item",
|
||||
"--item-error",
|
||||
"collect",
|
||||
],
|
||||
)
|
||||
end_route = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"end",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"finish",
|
||||
"--route",
|
||||
"ok=__end__",
|
||||
],
|
||||
)
|
||||
|
||||
assert malformed.exit_code == 2
|
||||
assert "invalid JSON" in malformed.output
|
||||
assert "Traceback" not in malformed.output
|
||||
assert serial_limits.exit_code == 2
|
||||
assert "concurrent" in serial_limits.output
|
||||
assert "Traceback" not in serial_limits.output
|
||||
assert duplicate_request.exit_code == 2
|
||||
assert "duplicate --request" in duplicate_request.output
|
||||
assert "Traceback" not in duplicate_request.output
|
||||
assert duplicate_resume.exit_code == 2
|
||||
assert "duplicate --resume" in duplicate_resume.output
|
||||
assert "--bind-output" not in duplicate_resume.output
|
||||
assert "Traceback" not in duplicate_resume.output
|
||||
assert missing_collect_target.exit_code == 2
|
||||
assert "collect item error policy requires collect_to" in missing_collect_target.output
|
||||
assert "Traceback" not in missing_collect_target.output
|
||||
assert end_route.exit_code == 2
|
||||
assert "No such option" in end_route.output
|
||||
assert "--route" in end_route.output
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_wf_draft_add_control_command_help_is_type_specific() -> None:
|
||||
interrupt = runner.invoke(app, ["draft", "add", "interrupt", "--help"])
|
||||
foreach = runner.invoke(app, ["draft", "add", "foreach", "--help"])
|
||||
join = runner.invoke(app, ["draft", "add", "join", "--help"])
|
||||
end = runner.invoke(app, ["draft", "add", "end", "--help"])
|
||||
|
||||
assert interrupt.exit_code == foreach.exit_code == join.exit_code == end.exit_code == 0
|
||||
assert "--request-schema-file" in interrupt.output
|
||||
assert "--resume-schema-file" in interrupt.output
|
||||
assert "--request" in interrupt.output
|
||||
assert "--resume" in interrupt.output
|
||||
assert "--outcome" in interrupt.output
|
||||
assert "--max-active" not in interrupt.output
|
||||
assert "--mode" in foreach.output
|
||||
assert "--max-active" in foreach.output
|
||||
assert "--max-outstanding" in foreach.output
|
||||
assert "--item-error" in foreach.output
|
||||
assert "--collect-to" in foreach.output
|
||||
assert "--request-schema-file" not in foreach.output
|
||||
assert "--from-step" in join.output
|
||||
assert "--route" in join.output
|
||||
assert "--request-schema-file" not in join.output
|
||||
assert "--outcome" in end.output
|
||||
assert "--route" not in end.output
|
||||
|
||||
|
||||
def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
|
||||
result = runner.invoke(app, ["draft", "--help"])
|
||||
|
||||
|
||||
@@ -1393,6 +1393,162 @@ def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||
assert "workflow.draft_workspaces.add_step" not in rpc_methods
|
||||
|
||||
|
||||
def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
rpc_methods: list[str] = []
|
||||
original_call = RpcClientTransport._call
|
||||
|
||||
async def recording_call(
|
||||
self: RpcClientTransport, method: str, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
rpc_methods.append(method)
|
||||
return await original_call(self, method, params)
|
||||
|
||||
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||
runner = CliRunner()
|
||||
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
|
||||
request_schema = tmp_path / "request.json"
|
||||
request_schema.write_text(
|
||||
'{"type":"object","properties":{"value":{"type":"string"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
resume_schema = tmp_path / "resume.json"
|
||||
resume_schema.write_text(
|
||||
'{"type":"object","properties":{"decision":{"type":"string"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cases = [
|
||||
(
|
||||
"interrupt",
|
||||
[
|
||||
"--kind",
|
||||
"review",
|
||||
"--request-schema-file",
|
||||
str(request_schema),
|
||||
"--resume-schema-file",
|
||||
str(resume_schema),
|
||||
"--request",
|
||||
"input.value=value",
|
||||
"--resume",
|
||||
"decision=state.decision",
|
||||
"--outcome",
|
||||
"submitted",
|
||||
"--outcome",
|
||||
"cancelled",
|
||||
"--route",
|
||||
"submitted=__end__",
|
||||
"--route",
|
||||
"cancelled=__end__",
|
||||
],
|
||||
{
|
||||
"kind": "review",
|
||||
"request": [{"target": "value", "path": "input.value"}],
|
||||
"resume": [{"source": "decision", "target": "state.decision"}],
|
||||
"request_schema": {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
"resume_schema": {
|
||||
"type": "object",
|
||||
"properties": {"decision": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
"outcomes": ["submitted", "cancelled"],
|
||||
},
|
||||
),
|
||||
(
|
||||
"foreach",
|
||||
[
|
||||
"--over",
|
||||
"input.items",
|
||||
"--as",
|
||||
"item",
|
||||
"--mode",
|
||||
"concurrent",
|
||||
"--item-error",
|
||||
"collect",
|
||||
"--collect-to",
|
||||
"state.errors",
|
||||
"--max-active",
|
||||
"2",
|
||||
"--max-outstanding",
|
||||
"5",
|
||||
"--route",
|
||||
"loop=call",
|
||||
"--route",
|
||||
"done=__end__",
|
||||
"--route",
|
||||
"completed_with_errors=__end__",
|
||||
],
|
||||
{
|
||||
"over": "input.items",
|
||||
"as": "item",
|
||||
"mode": "concurrent",
|
||||
"item_error": {"action": "collect", "collect_to": "state.errors"},
|
||||
"concurrent": {
|
||||
"max_active": 2,
|
||||
"max_outstanding": 5,
|
||||
"interrupt": "quiesce",
|
||||
},
|
||||
},
|
||||
),
|
||||
("join", ["--route", "done=__end__"], {}),
|
||||
("end", ["--outcome", "ok"], {"outcome": "ok"}),
|
||||
]
|
||||
|
||||
for command, command_args, expected in cases:
|
||||
workspace_id = f"add_{command}_ws"
|
||||
created = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"create",
|
||||
workspace_id,
|
||||
"--capability",
|
||||
"wf.std.constant",
|
||||
"--name",
|
||||
f"add_{command}",
|
||||
],
|
||||
)
|
||||
assert created.exit_code == 0, created.output
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"add",
|
||||
command,
|
||||
workspace_id,
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
command,
|
||||
"--from-step",
|
||||
"call",
|
||||
*command_args,
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output)["revision"] == 2
|
||||
assert rpc_methods[-1] == "workflow.draft_workspaces.add_step"
|
||||
inspected = runner.invoke(
|
||||
app,
|
||||
[*base_args, "draft", "inspect", workspace_id, "--include-draft"],
|
||||
)
|
||||
assert inspected.exit_code == 0, inspected.output
|
||||
persisted_step = json.loads(inspected.output)["draft"]["steps"][command]
|
||||
actual_payload = persisted_step[command]
|
||||
for field, value in expected.items():
|
||||
assert actual_payload[field] == value
|
||||
|
||||
|
||||
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user