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
+279 -16
View File
@@ -4,9 +4,12 @@ from pathlib import Path
from typing import Annotated, Literal
import typer
from pydantic import ValidationError
from pydantic import TypeAdapter, ValidationError
from wf_artifacts.drafts.models import (
DraftChooseClause,
DraftChoosePayload,
DraftChooseStep,
DraftEndPayload,
DraftEndStep,
DraftForeachPayload,
@@ -14,15 +17,24 @@ from wf_artifacts.drafts.models import (
DraftInterruptPayload,
DraftInterruptStep,
DraftJoinStep,
DraftMatchCase,
DraftMatchPayload,
DraftMatchStep,
DraftStep,
DraftSubgraphPayload,
DraftSubgraphStep,
DraftWhenPayload,
DraftWhenStep,
)
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.conditions import Condition
from wf_core.models.schemas import SchemaRef
from wf_core.models.steps import (
ForeachConcurrentPolicy,
)
from wf_core.models.workflow_refs import WorkflowRef
from .draft_options import (
_parse_map_flags,
@@ -39,6 +51,10 @@ app = typer.Typer(
no_args_is_help=True,
)
_condition_adapter = TypeAdapter(Condition)
_choose_clauses_adapter = TypeAdapter(list[DraftChooseClause])
_match_cases_adapter = TypeAdapter(list[DraftMatchCase])
def _submit_step(
ctx: typer.Context,
@@ -422,19 +438,266 @@ def add_end_step(
)
# 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 (
"when",
"choose",
"match",
"subgraph",
):
app.add_typer(
typer.Typer(
name=_name,
help=f"Add a {_name} step (available in a later CLI task).",
no_args_is_help=True,
),
name=_name,
@app.command("when")
def add_when_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.")],
condition_file: Annotated[
Path, typer.Option("--condition-file", help="JSON condition expression.")
],
then: Annotated[str, typer.Option("--then", help="Target when true.")],
otherwise: Annotated[
str, typer.Option("--otherwise", help="Target when false.")
] = "__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,
)