feat: add draft decision and subgraph commands

This commit is contained in:
lda
2026-07-21 07:18:02 +07:00 Verified
parent 18e8fb90e3
commit f5bac90818
7 changed files with 759 additions and 141 deletions
-47
View File
@@ -1,47 +0,0 @@
# Task 3 Report: Expose Generic Insertion Through Python JSON-RPC
## Status
Validated the existing five-file Task 3 implementation without redesigning it.
No CLI or ordinary documentation files were changed.
## Validation
- Focused pytest with xdist disabled and `C:\tmp\task-3-pytest`: `48 passed, 101 warnings in 29.24s`.
- Ruff check on the five changed files: `All checks passed!`.
- `git diff --check`: passed; only LF/CRLF normalization warnings were emitted.
- basedpyright: `0 errors, 0 warnings, 0 notes` within the 120-second bound.
The pytest warnings are dependency deprecations from `fastapi_jsonrpc`.
The repository `uv` shim was not executable, so validation used the installed
uv binary directly. The task-owned pytest temp directory was removed.
## Scope Review
The diff contains exactly the five files named by the brief. It adds typed RPC
parameter models, the `workflow.draft_workspaces.add_step` server method, the
remote client method with alias-preserving JSON serialization, and coverage for
all nine typed variants, malformed requests, and a typed interrupt round-trip.
## Commit
The existing Task 3 five-file diff is being committed on `main`.
## Concerns
No implementation concerns found in the focused validation. Full-repository
tests were not run because the brief requested scoped validation.
## Task 3 Minor Review Fix Evidence
- Added RPC coverage for an interrupt whose `request_schema` and `resume_schema`
are explicitly `null`; the draft round trip asserts both values remain null.
- Strengthened the nine-variant client serialization test with independent
expected wire fields, including the `as` and `if` aliases and default keys;
corrected fixture indentation.
- Focused pytest was run with `-n0` and `C:\tmp\task-3-pytest-minors` as the
basetemp: the first run completed with `46 passed, 3 failed`; the failures
were test expectations that omitted intentional serialized defaults for
`use`, `foreach`, and `subgraph`. The corrected expectations were not rerun
because verification was stopped at the user's request.
- Ruff format/check for the touched tests was not run for the same reason.
-72
View File
@@ -1,72 +0,0 @@
# Task 7 Report: Presenter Synchronization And Session End
## Outcome
Implemented bidirectional LAN presentation synchronization for `/presenter` using the existing `usePresentationSync` controller and `PresentationPairingPanel` UI with the `presenter` role.
The presenter URL hash remains canonical. Local navigation continues to use the existing hash parser and `presenterHashForNote` paths, while remote locations assign `window.location.hash` only when the requested hash differs. The existing hook's remote-in-flight guard consumes that update without publishing a feedback revision.
## Implementation
- Mounted `usePresentationSync` in `PresenterRoute` with the current URL hash and presenter role.
- Applied remote note and Q&A hashes through `window.location.hash`, preserving the existing `hashchange` parser and rendering flow.
- Passed the controller to `PresenterNavigationBar` and rendered the shared pairing panel beside Previous/Next controls, outside speaker-note content.
- Enabled the presenter's existing shared end-session confirmation and ended-state UI.
- Preserved rapid key navigation, swipe callbacks and exclusions, sidebar/Q&A links, covered state, and initial auto-scroll behavior.
- Preserved the intentional mobile navigation CSS: centered auto margins remain in place, the viewport-wide sticky surface uses `::before`, and no negative `margin-inline` was introduced.
- Expanded the navigation grid for the pairing control and kept Next alignment explicit after adding the fourth grid item.
## TDD Evidence
RED was observed with four route tests failing because the presenter route did not mount the panel/controller, apply remote hashes, expose end-session UI, or render failure state. Existing presenter shell tests remained green.
GREEN coverage verifies:
- the pairing panel is inside the stable presenter navigation area;
- canonical Previous/Next and Q&A links and hook hash updates;
- audience-originated note and Q&A hashes update presenter content;
- presenter end confirmation calls `endSession` and ended state is displayed;
- local arrow-key navigation remains available after synchronization failure;
- existing rapid key navigation, swipe behavior/exclusions, sidebar behavior, and auto-scroll tests remain passing.
Remote-update feedback suppression is additionally covered by the existing `usePresentationSync` tests and implemented by its `remoteHashInFlightRef` guard.
## Verification
```text
pnpm --dir web --filter @lda/console test -- src/presentation/presenter
5 test files passed; 28 tests passed.
pnpm --dir web --filter @lda/console typecheck
@lda/presentation-sync build and console TypeScript project build passed.
git diff --check
Passed with no whitespace errors.
```
## Self-Review
No critical or important findings. The change is limited to the presenter synchronization seam and tests. The untracked active LAN synchronization plan was not staged. The pre-existing intentional `presenter.css` mobile sticky-navigation edit was retained and verified as part of this task.
## Important Finding Fix: Pairing Persistence On Q&A
The original Task 7 render condition mounted `PresenterNavigationBar` only when `navigation.note` existed. Valid discussion hashes resolve with `note: null`, so Q&A content remained visible but the pairing controls, session-end action, and ended state disappeared.
The stable navigation surface now renders for either a valid presenter note or a resolved discussion branch. Discussion routes pass a nullable progress index and display `Q&A`; Previous and Next remain disabled because no note or destination is fabricated. The existing Q&A content, hash parsing, local note navigation, and presenter mobile CSS are unchanged.
TDD evidence:
```text
RED: pnpm --dir web --filter @lda/console test -- src/presentation/presenter/PresenterRoute.test.tsx
1 test file failed; 1 failed and 9 passed (10 total).
The regression test could not find the Pair presentation button on #discuss/where-is-ai-agent.
GREEN: pnpm --dir web --filter @lda/console test -- src/presentation/presenter/PresenterRoute.test.tsx
1 test file passed; 10 tests passed.
FINAL: pnpm --dir web --filter @lda/console test -- src/presentation/presenter/PresenterRoute.test.tsx src/presentation/presenter/PresenterShell.test.tsx
2 test files passed; 16 tests passed.
pnpm --dir web --filter @lda/console typecheck
@lda/presentation-sync build and console TypeScript project build passed.
```
+1 -1
View File
@@ -133,7 +133,7 @@ def _canonical_draft_if_valid(
""" """
if validation_status != "valid": if validation_status != "valid":
return draft return draft
return WorkflowDraft.model_validate(draft).model_dump(mode="json") return WorkflowDraft.model_validate(draft).model_dump(mode="json", by_alias=True)
def _revision_conflict_payload( def _revision_conflict_payload(
+279 -16
View File
@@ -4,9 +4,12 @@ from pathlib import Path
from typing import Annotated, Literal from typing import Annotated, Literal
import typer import typer
from pydantic import ValidationError from pydantic import TypeAdapter, ValidationError
from wf_artifacts.drafts.models import ( from wf_artifacts.drafts.models import (
DraftChooseClause,
DraftChoosePayload,
DraftChooseStep,
DraftEndPayload, DraftEndPayload,
DraftEndStep, DraftEndStep,
DraftForeachPayload, DraftForeachPayload,
@@ -14,15 +17,24 @@ from wf_artifacts.drafts.models import (
DraftInterruptPayload, DraftInterruptPayload,
DraftInterruptStep, DraftInterruptStep,
DraftJoinStep, DraftJoinStep,
DraftMatchCase,
DraftMatchPayload,
DraftMatchStep,
DraftStep, DraftStep,
DraftSubgraphPayload,
DraftSubgraphStep,
DraftWhenPayload,
DraftWhenStep,
) )
from wf_cli.context import load_cli_context_from_typer as load_cli_context from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.io import emit_json from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation from wf_cli.remote_errors import run_cli_operation
from wf_core.models.conditions import Condition
from wf_core.models.schemas import SchemaRef from wf_core.models.schemas import SchemaRef
from wf_core.models.steps import ( from wf_core.models.steps import (
ForeachConcurrentPolicy, ForeachConcurrentPolicy,
) )
from wf_core.models.workflow_refs import WorkflowRef
from .draft_options import ( from .draft_options import (
_parse_map_flags, _parse_map_flags,
@@ -39,6 +51,10 @@ app = typer.Typer(
no_args_is_help=True, no_args_is_help=True,
) )
_condition_adapter = TypeAdapter(Condition)
_choose_clauses_adapter = TypeAdapter(list[DraftChooseClause])
_match_cases_adapter = TypeAdapter(list[DraftMatchCase])
def _submit_step( def _submit_step(
ctx: typer.Context, ctx: typer.Context,
@@ -422,19 +438,266 @@ def add_end_step(
) )
# These subgroups establish the public boundary for the next command slice. @app.command("when")
# They intentionally have no bodies until their typed decision options are ready. def add_when_step(
for _name in ( ctx: typer.Context,
"when", workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
"choose", revision: Annotated[
"match", int, typer.Option("--revision", min=1, help="Expected workspace revision.")
"subgraph", ],
): step_id: Annotated[str, typer.Option("--step", help="New draft step id.")],
app.add_typer( condition_file: Annotated[
typer.Typer( Path, typer.Option("--condition-file", help="JSON condition expression.")
name=_name, ],
help=f"Add a {_name} step (available in a later CLI task).", then: Annotated[str, typer.Option("--then", help="Target when true.")],
no_args_is_help=True, otherwise: Annotated[
), str, typer.Option("--otherwise", help="Target when false.")
name=_name, ] = "__end__",
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 a boolean decision whose targets are embedded in the step."""
try:
condition = _condition_adapter.validate_python(
parse_json_file(condition_file, option_name="--condition-file")
)
step = DraftWhenStep(
when=DraftWhenPayload.model_validate(
{"if": condition, "then": then, "otherwise": otherwise}
)
)
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,
)
@app.command("choose")
def add_choose_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.")],
clauses_file: Annotated[
Path,
typer.Option("--clauses-file", help="JSON array of ordered if/then clauses."),
],
default: Annotated[
str, typer.Option("--default", help="Target when no clause matches.")
] = "__end__",
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 ordered first-true decision with embedded targets."""
try:
clauses = _choose_clauses_adapter.validate_python(
parse_json_file(clauses_file, option_name="--clauses-file")
)
step = DraftChooseStep(
choose=DraftChoosePayload(clauses=clauses, default=default)
)
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,
)
@app.command("match")
def add_match_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.")],
value: Annotated[str, typer.Option("--value", help="Graph path to compare.")],
cases_file: Annotated[
Path,
typer.Option("--cases-file", help="JSON array of ordered equals/then cases."),
],
default: Annotated[
str, typer.Option("--default", help="Target when no case matches.")
] = "__end__",
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 ordered scalar match decision with embedded targets."""
try:
cases = _match_cases_adapter.validate_python(
parse_json_file(cases_file, option_name="--cases-file")
)
step = DraftMatchStep(
match=DraftMatchPayload(value=value, cases=cases, default=default)
)
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,
)
@app.command("subgraph")
def add_subgraph_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.")],
workflow_name: Annotated[
str | None,
typer.Option("--workflow-name", help="Local child workflow registry name."),
] = None,
artifact_id: Annotated[
str | None,
typer.Option("--artifact-id", help="Saved child workflow artifact id."),
] = None,
artifact_version: Annotated[
int | None,
typer.Option("--artifact-version", min=1, help="Saved artifact version."),
] = None,
description: Annotated[
str | None, typer.Option("--description", help="Boundary description.")
] = None,
input_schema_file: Annotated[
Path | None,
typer.Option("--input-schema-file", help="Child input JSON Schema."),
] = None,
output_schema_file: Annotated[
Path | None,
typer.Option("--output-schema-file", help="Child output JSON Schema."),
] = None,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help="Input binding GRAPH_SOURCE=LOCAL_TARGET. Repeat as needed.",
),
] = None,
output_mapping: Annotated[
list[str] | None,
typer.Option(
"--bind-output",
help="Output binding LOCAL_SOURCE=STATE_TARGET. Repeat as needed.",
),
] = None,
outcome: Annotated[
list[str] | None,
typer.Option("--outcome", help="Declared child outcome. Repeat as needed."),
] = 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 child-workflow boundary with an explicit reference and contract."""
if workflow_name is not None:
if artifact_id is not None or artifact_version is not None:
raise typer.BadParameter(
"--workflow-name cannot be combined with "
"--artifact-id/--artifact-version"
)
workflow_data: dict[str, str | int] = {"name": workflow_name}
else:
if artifact_id is None or artifact_version is None:
raise typer.BadParameter(
"use --workflow-name or both --artifact-id and --artifact-version"
)
workflow_data = {"artifact_id": artifact_id, "version": artifact_version}
input_map = _parse_step_input_map_flags(input_mapping, option_name="--input")
output_map = _parse_output_map_flags(output_mapping)
try:
workflow = WorkflowRef.model_validate(workflow_data)
input_schema = (
SchemaRef.model_validate(
parse_json_file(input_schema_file, option_name="--input-schema-file")
)
if input_schema_file is not None
else SchemaRef(type="object")
)
output_schema = (
SchemaRef.model_validate(
parse_json_file(output_schema_file, option_name="--output-schema-file")
)
if output_schema_file is not None
else SchemaRef(type="object")
)
step = DraftSubgraphStep(
subgraph=DraftSubgraphPayload.model_validate(
{
"workflow": workflow,
"desc": description,
"input_schema": input_schema,
"output_schema": output_schema,
"input": [
{"path": source, "target": target}
for source, target in input_map.items()
],
"output": [
{"source": source, "target": target}
for source, target in output_map.items()
],
"outcomes": outcome or ["ok"],
}
)
)
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,
) )
+373
View File
@@ -701,6 +701,379 @@ def test_wf_draft_add_control_command_help_is_type_specific() -> None:
assert "--route" not in end.output assert "--route" not in end.output
def test_wf_draft_add_decisions_build_ordered_typed_steps(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": len(calls) + 1, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr(
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
)
condition_file = tmp_path / "condition.json"
condition_file.write_text(
'{"op":"exists","path":"state.report"}', encoding="utf-8"
)
clauses_file = tmp_path / "clauses.json"
clauses_file.write_text(
'[{"if":{"op":"exists","path":"state.report"},"then":"publish"},'
'{"if":{"op":"exists","path":"state.error"},"then":"revise"}]',
encoding="utf-8",
)
cases_file = tmp_path / "cases.json"
cases_file.write_text(
'[{"equals":"ready","then":"publish"},{"equals":2,"then":"retry"},'
'{"equals":true,"then":"approve"},{"equals":null,"then":"revise"}]',
encoding="utf-8",
)
invocations = [
[
"when",
"--condition-file",
str(condition_file),
"--then",
"publish",
"--otherwise",
"revise",
],
[
"choose",
"--clauses-file",
str(clauses_file),
"--default",
"__end__",
],
[
"match",
"--value",
"state.status",
"--cases-file",
str(cases_file),
"--default",
"__end__",
],
]
for revision, invocation in enumerate(invocations, start=1):
result = runner.invoke(
app,
[
"draft",
"add",
*invocation[:1],
"workspace",
"--revision",
str(revision),
"--step",
invocation[0],
"--from-step",
"start",
"--from-outcome",
"ok",
*invocation[1:],
],
)
assert result.exit_code == 0, result.output
assert calls[0]["step"].model_dump(mode="json", by_alias=True) == {
"when": {
"if": {"op": "exists", "path": "state.report"},
"then": "publish",
"otherwise": "revise",
}
}
assert calls[1]["step"].model_dump(mode="json", by_alias=True) == {
"choose": {
"clauses": [
{
"if": {"op": "exists", "path": "state.report"},
"then": "publish",
},
{
"if": {"op": "exists", "path": "state.error"},
"then": "revise",
},
],
"default": "__end__",
}
}
assert calls[2]["step"].model_dump(mode="json", by_alias=True) == {
"match": {
"value": "state.status",
"cases": [
{"equals": "ready", "then": "publish"},
{"equals": 2, "then": "retry"},
{"equals": True, "then": "approve"},
{"equals": None, "then": "revise"},
],
"default": "__end__",
}
}
assert all(call["routes"] is None for call in calls)
assert all(call["incoming"].step_id == "start" for call in calls)
def test_wf_draft_add_decisions_reject_bad_files_and_generic_routes(
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
)
empty_clauses = tmp_path / "empty.json"
empty_clauses.write_text("[]", encoding="utf-8")
object_cases = tmp_path / "object.json"
object_cases.write_text("{}", encoding="utf-8")
condition_file = tmp_path / "condition.json"
condition_file.write_text(
'{"op":"exists","path":"state.report"}', encoding="utf-8"
)
empty = runner.invoke(
app,
[
"draft",
"add",
"choose",
"ws",
"--revision",
"1",
"--step",
"choose",
"--clauses-file",
str(empty_clauses),
],
)
non_array = runner.invoke(
app,
[
"draft",
"add",
"match",
"ws",
"--revision",
"1",
"--step",
"match",
"--value",
"state.status",
"--cases-file",
str(object_cases),
],
)
generic_route = runner.invoke(
app,
[
"draft",
"add",
"when",
"ws",
"--revision",
"1",
"--step",
"when",
"--condition-file",
str(condition_file),
"--then",
"yes",
"--route",
"true=yes",
],
)
assert empty.exit_code == 2
assert "Traceback" not in empty.output
assert non_array.exit_code == 2
assert "Traceback" not in non_array.output
assert generic_route.exit_code == 2
assert "--route" in generic_route.output
assert calls == []
def test_wf_draft_add_subgraph_builds_name_and_artifact_contracts(
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": len(calls) + 1, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr(
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
)
input_schema = tmp_path / "input.json"
input_schema.write_text(
'{"type":"object","properties":{"topic":{"type":"string"}}}',
encoding="utf-8",
)
output_schema = tmp_path / "output.json"
output_schema.write_text(
'{"type":"object","properties":{"report":{"type":"string"}}}',
encoding="utf-8",
)
named = runner.invoke(
app,
[
"draft",
"add",
"subgraph",
"workspace",
"--revision",
"1",
"--step",
"child",
"--workflow-name",
"child_workflow",
"--description",
"Generate the child report.",
"--input-schema-file",
str(input_schema),
"--output-schema-file",
str(output_schema),
"--input",
"state.topic=topic",
"--bind-output",
"report=state.report",
"--outcome",
"ok",
"--outcome",
"error",
"--route",
"ok=publish",
"--route",
"error=revise",
],
)
artifact = runner.invoke(
app,
[
"draft",
"add",
"subgraph",
"workspace",
"--revision",
"2",
"--step",
"saved_child",
"--artifact-id",
"child_report",
"--artifact-version",
"2",
],
)
assert named.exit_code == 0, named.output
assert artifact.exit_code == 0, artifact.output
named_payload = calls[0]["step"].model_dump(mode="json", by_alias=True)[
"subgraph"
]
assert named_payload["workflow"] == {"name": "child_workflow"}
assert named_payload["desc"] == "Generate the child report."
assert named_payload["input_schema"]["properties"] == {
"topic": {"type": "string"}
}
assert named_payload["output_schema"]["properties"] == {
"report": {"type": "string"}
}
assert named_payload["input"] == [
{"target": "topic", "path": "state.topic"}
]
assert named_payload["output"] == [
{"source": "report", "target": "state.report"}
]
assert named_payload["outcomes"] == ["ok", "error"]
assert calls[0]["routes"] == {"ok": "publish", "error": "revise"}
artifact_payload = calls[1]["step"].model_dump(mode="json", by_alias=True)[
"subgraph"
]
assert artifact_payload["workflow"] == {
"artifact_id": "child_report",
"version": 2,
}
assert artifact_payload["outcomes"] == ["ok"]
def test_wf_draft_add_subgraph_rejects_invalid_reference_combinations(
monkeypatch,
) -> 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
)
invalid_refs = [
[],
["--workflow-name", " "],
["--workflow-name", "child", "--artifact-id", "saved", "--artifact-version", "1"],
["--artifact-id", "saved"],
["--artifact-version", "1"],
]
for ref_args in invalid_refs:
result = runner.invoke(
app,
[
"draft",
"add",
"subgraph",
"workspace",
"--revision",
"1",
"--step",
"child",
*ref_args,
],
)
assert result.exit_code == 2
assert "Traceback" not in result.output
assert calls == []
def test_wf_draft_add_decision_and_subgraph_help_is_type_specific() -> None:
when = runner.invoke(app, ["draft", "add", "when", "--help"])
choose = runner.invoke(app, ["draft", "add", "choose", "--help"])
match = runner.invoke(app, ["draft", "add", "match", "--help"])
subgraph = runner.invoke(app, ["draft", "add", "subgraph", "--help"])
assert when.exit_code == choose.exit_code == match.exit_code == subgraph.exit_code == 0
assert "--condition-file" in when.output
assert "--then" in when.output
assert "--route" not in when.output
assert "--clauses-file" in choose.output
assert "--default" in choose.output
assert "--route" not in choose.output
assert "--value" in match.output
assert "--cases-file" in match.output
assert "--route" not in match.output
assert "--workflow-name" in subgraph.output
assert "--artifact-id" in subgraph.output
assert "--artifact-version" in subgraph.output
assert "--input-schema-file" in subgraph.output
assert "--output-schema-file" in subgraph.output
assert "--route" in subgraph.output
def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None: def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
result = runner.invoke(app, ["draft", "--help"]) result = runner.invoke(app, ["draft", "--help"])
+2 -2
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import click
import pytest import pytest
import typer import typer
from typer.core import TyperCommand
from wf_api import WorkflowApi from wf_api import WorkflowApi
from wf_cli.context import ( from wf_cli.context import (
@@ -22,7 +22,7 @@ from .conftest import write_python_source_config
def _typer_context(obj: object | None) -> typer.Context: def _typer_context(obj: object | None) -> typer.Context:
ctx = typer.Context(click.Command("wf")) ctx = typer.Context(TyperCommand(name="wf"))
ctx.obj = obj ctx.obj = obj
return ctx return ctx
+104 -3
View File
@@ -1420,6 +1420,20 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
'{"type":"object","properties":{"decision":{"type":"string"}}}', '{"type":"object","properties":{"decision":{"type":"string"}}}',
encoding="utf-8", encoding="utf-8",
) )
condition_file = tmp_path / "condition.json"
condition_file.write_text(
'{"op":"exists","path":"state.value"}', encoding="utf-8"
)
clauses_file = tmp_path / "clauses.json"
clauses_file.write_text(
'[{"if":{"op":"exists","path":"state.value"},"then":"call"}]',
encoding="utf-8",
)
cases_file = tmp_path / "cases.json"
cases_file.write_text(
'[{"equals":"ready","then":"call"},{"equals":null,"then":"__end__"}]',
encoding="utf-8",
)
cases = [ cases = [
( (
"interrupt", "interrupt",
@@ -1459,6 +1473,7 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
}, },
"outcomes": ["submitted", "cancelled"], "outcomes": ["submitted", "cancelled"],
}, },
{"submitted": "__end__", "cancelled": "__end__"},
), ),
( (
"foreach", "foreach",
@@ -1495,12 +1510,92 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
"interrupt": "quiesce", "interrupt": "quiesce",
}, },
}, },
{
"loop": "call",
"done": "__end__",
"completed_with_errors": "__end__",
},
),
("join", ["--route", "done=__end__"], {}, {"done": "__end__"}),
("end", ["--outcome", "ok"], {"outcome": "ok"}, None),
(
"when",
[
"--condition-file",
str(condition_file),
"--then",
"call",
"--otherwise",
"__end__",
],
{
"if": {"op": "exists", "path": "state.value"},
"then": "call",
"otherwise": "__end__",
},
None,
),
(
"choose",
["--clauses-file", str(clauses_file), "--default", "__end__"],
{
"clauses": [
{
"if": {"op": "exists", "path": "state.value"},
"then": "call",
}
],
"default": "__end__",
},
None,
),
(
"match",
[
"--value",
"state.value",
"--cases-file",
str(cases_file),
"--default",
"__end__",
],
{
"value": "state.value",
"cases": [
{"equals": "ready", "then": "call"},
{"equals": None, "then": "__end__"},
],
"default": "__end__",
},
None,
),
(
"subgraph",
[
"--workflow-name",
"child",
"--input",
"input.value=value",
"--bind-output",
"value=state.child_value",
"--outcome",
"ok",
"--route",
"ok=__end__",
],
{
"workflow": {"name": "child"},
"input": [{"target": "value", "path": "input.value"}],
"output": [
{"source": "value", "target": "state.child_value"}
],
"outcomes": ["ok"],
},
{"ok": "__end__"},
), ),
("join", ["--route", "done=__end__"], {}),
("end", ["--outcome", "ok"], {"outcome": "ok"}),
] ]
for command, command_args, expected in cases: for command, command_args, expected, expected_routes in cases:
workspace_id = f"add_{command}_ws" workspace_id = f"add_{command}_ws"
created = runner.invoke( created = runner.invoke(
app, app,
@@ -1544,9 +1639,15 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
) )
assert inspected.exit_code == 0, inspected.output assert inspected.exit_code == 0, inspected.output
persisted_step = json.loads(inspected.output)["draft"]["steps"][command] persisted_step = json.loads(inspected.output)["draft"]["steps"][command]
draft = json.loads(inspected.output)["draft"]
actual_payload = persisted_step[command] actual_payload = persisted_step[command]
for field, value in expected.items(): for field, value in expected.items():
assert actual_payload[field] == value assert actual_payload[field] == value
assert draft["routes"]["call"]["ok"] == command
if expected_routes is None:
assert command not in draft["routes"]
else:
assert draft["routes"][command] == expected_routes
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback( def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(