feat: add draft decision and subgraph commands
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
```
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user