fix via review findings
This commit is contained in:
@@ -8,12 +8,11 @@
|
||||
|
||||
## Draft data-shaping parity
|
||||
|
||||
- [x] `wf draft bind` rejects nested node-local targets such as
|
||||
`local.report.title`, even though canonical `LocalPath`, the runtime binding
|
||||
resolver, and `WorkflowBuilder` support nested local paths.
|
||||
- [x] Capability-step authoring persists nested input targets but silently skips
|
||||
workflow input/state schema projection when a target has more than one path
|
||||
segment.
|
||||
- [x] `wf draft bind` accepts nested node-local targets such as
|
||||
`local.report.title`, consistently with canonical `LocalPath`, the runtime
|
||||
binding resolver, and `WorkflowBuilder`.
|
||||
- [x] Capability-step authoring projects workflow input/state schemas for nested
|
||||
local targets while preserving their canonical binding paths.
|
||||
- [x] An atomic API/RPC/CLI helper assembles one structured node input from
|
||||
multiple graph paths. Canonical replacement accepts several bindings in one
|
||||
revision-checked edit without an intermediate state object or raw JSON Patch.
|
||||
|
||||
@@ -120,6 +120,11 @@ equivalent to the complete declared `output_schema`; a root literal must be a
|
||||
mapping that validates against the complete declared `output_schema`. The
|
||||
operation does not replace or infer the root output schema.
|
||||
|
||||
`context.*` path sources cannot target `.`. Context values do not have a
|
||||
statically declared schema, so the operation cannot perform the exact
|
||||
whole-schema comparison required for a root binding. They remain valid for
|
||||
declared non-root output targets.
|
||||
|
||||
## Path Source And Schema Rules
|
||||
|
||||
For `input.*` and `state.*` path bindings:
|
||||
|
||||
@@ -681,6 +681,11 @@ class WorkflowDraftAuthoringApi:
|
||||
for index, binding in enumerate(bindings):
|
||||
target_parts = binding.target.parts
|
||||
if isinstance(binding, InputPathBinding):
|
||||
if binding.path.root == "context" and not target_parts:
|
||||
raise ValueError(
|
||||
f"bindings[{index}].path {str(binding.path)!r} "
|
||||
"cannot target '.' because context schemas are not declared"
|
||||
)
|
||||
source_schema = source_schemas.get(index)
|
||||
if source_schema is None:
|
||||
if not schema_path_exists(projected, target_parts):
|
||||
@@ -1082,7 +1087,7 @@ class WorkflowDraftAuthoringApi:
|
||||
if value is not None
|
||||
}
|
||||
if metadata:
|
||||
CapabilityStepUpdate.model_validate(metadata)
|
||||
_ = CapabilityStepUpdate.model_validate(metadata)
|
||||
|
||||
spec = self.context.specs.get_qualified_spec(capability_name)
|
||||
output_schema = (
|
||||
|
||||
+12
-3
@@ -4,6 +4,8 @@ from collections.abc import Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, SchemaError
|
||||
|
||||
from wf_artifacts import (
|
||||
DraftWorkspaceStore,
|
||||
compile_workflow_draft,
|
||||
@@ -55,10 +57,17 @@ def _empty_object_schema() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _validated_schema_object(value: object, *, field_name: str) -> dict[str, Any]:
|
||||
"""Return an isolated schema object after validating the public envelope."""
|
||||
"""Return an isolated, structurally valid JSON Schema object."""
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{field_name} must be a JSON object")
|
||||
return deepcopy(value)
|
||||
schema = deepcopy(value)
|
||||
try:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
raise ValueError(
|
||||
f"{field_name} is not valid JSON Schema: {exc.message}"
|
||||
) from exc
|
||||
return schema
|
||||
|
||||
|
||||
def _validated_workflow_outcomes(outcomes: Sequence[str]) -> list[str]:
|
||||
@@ -68,7 +77,7 @@ def _validated_workflow_outcomes(outcomes: Sequence[str]) -> list[str]:
|
||||
raise ValueError("workflow outcomes must contain at least one value")
|
||||
if any(not isinstance(value, str) or not value.strip() for value in values):
|
||||
raise ValueError("workflow outcomes must not contain blank values")
|
||||
if len(set(values)) != len(values):
|
||||
if len({value.strip() for value in values}) != len(values):
|
||||
raise ValueError("workflow outcomes must be unique")
|
||||
return values
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts.drafts import (
|
||||
@@ -121,10 +122,11 @@ def replace_validated_draft_document(
|
||||
WorkflowDraft.model_validate(draft)
|
||||
if draft == workspace.draft:
|
||||
return summarize_draft_workspace(workspace)
|
||||
stored_draft = deepcopy(draft)
|
||||
next_workspace = workspace.model_copy(
|
||||
update={
|
||||
"revision": workspace.revision + 1,
|
||||
"draft": draft,
|
||||
"draft": stored_draft,
|
||||
"updated_at_epoch_ms": _now_ms(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -176,6 +176,8 @@ def add_step_from_capability(
|
||||
`--input state.title=report.title --input state.summary=report.summary`
|
||||
`--bind-output title=state.title --bind-output summary=state.summary`
|
||||
"""
|
||||
if route_from_step is None and route_from_outcome != "ok":
|
||||
raise typer.BadParameter("--from-outcome requires --from-step")
|
||||
convenience_input_selected = input_mapping is not None or input_value is not None
|
||||
if bindings_file is not None and convenience_input_selected:
|
||||
raise typer.BadParameter(
|
||||
@@ -378,6 +380,8 @@ def add_foreach_step(
|
||||
raise typer.BadParameter(
|
||||
"--max-active and --max-outstanding require --mode concurrent"
|
||||
)
|
||||
if collect_to is not None and item_error != "collect":
|
||||
raise typer.BadParameter("--collect-to requires --item-error collect")
|
||||
try:
|
||||
concurrent_options: dict[str, int] = {}
|
||||
if max_active is not None:
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
import typer
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from wf_api.surface import RouteSource
|
||||
from wf_api.draft_authoring import RouteSource
|
||||
from wf_core.models.steps import (
|
||||
InputBinding,
|
||||
InputPathBinding,
|
||||
@@ -81,6 +81,13 @@ def _parse_step_input_map_flags(
|
||||
expected="GRAPH_SOURCE=LOCAL_TARGET",
|
||||
)
|
||||
for source, target in parsed.items():
|
||||
try:
|
||||
GraphSourcePath.parse(source)
|
||||
except PathResolutionError as exc:
|
||||
raise typer.BadParameter(
|
||||
f"{option_name} source {source!r} is invalid; expected "
|
||||
"GRAPH_SOURCE=LOCAL_TARGET"
|
||||
) from exc
|
||||
if target.startswith("local."):
|
||||
bare_target = target.removeprefix("local.")
|
||||
raise typer.BadParameter(
|
||||
|
||||
@@ -445,9 +445,7 @@ def set_step_input(
|
||||
if selected_modes == 0:
|
||||
raise typer.BadParameter("provide --map/--value, --bindings-file, or --clear")
|
||||
if selected_modes > 1:
|
||||
raise typer.BadParameter(
|
||||
"--bindings-file and --clear cannot be combined with --map or --value"
|
||||
)
|
||||
raise typer.BadParameter("input modes are mutually exclusive")
|
||||
if merge and (literal_values or has_file or clear):
|
||||
raise typer.BadParameter(
|
||||
"--merge is supported only for compatibility map-only edits"
|
||||
|
||||
@@ -364,7 +364,7 @@ class AddStepFromCapabilityRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_both_input_forms(self) -> Self:
|
||||
if self.input_map is not None and self.input_bindings is not None:
|
||||
if {"input_map", "input_bindings"} <= self.model_fields_set:
|
||||
raise ValueError("input_map and input_bindings are mutually exclusive")
|
||||
return self
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from .app import create_rpc_app
|
||||
from .client import RpcWorkflowApiClient
|
||||
from .errors import WorkflowRpcError
|
||||
from .models import (
|
||||
AddDraftStepParams,
|
||||
AddStepFromCapabilityParams,
|
||||
AdminEmptyParams,
|
||||
BindDraftParams,
|
||||
@@ -37,6 +38,7 @@ from .models import (
|
||||
RemoveDraftRouteParams,
|
||||
RemoveDraftStepParams,
|
||||
ResumeRunParams,
|
||||
RouteSourceParams,
|
||||
SaveArtifactParams,
|
||||
SaveDeploymentParams,
|
||||
SetDraftContractParams,
|
||||
@@ -58,6 +60,7 @@ from .models import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AddDraftStepParams",
|
||||
"AddStepFromCapabilityParams",
|
||||
"AdminEmptyParams",
|
||||
"BindDraftParams",
|
||||
@@ -90,6 +93,7 @@ __all__ = [
|
||||
"RemoveDraftBindingParams",
|
||||
"RemoveDraftRouteParams",
|
||||
"RemoveDraftStepParams",
|
||||
"RouteSourceParams",
|
||||
"ResumeRunParams",
|
||||
"SaveArtifactParams",
|
||||
"SaveDeploymentParams",
|
||||
|
||||
@@ -88,7 +88,7 @@ def _validate_workflow_outcomes(outcomes: list[str]) -> None:
|
||||
raise ValueError("workflow outcomes must contain at least one value")
|
||||
if any(not outcome.strip() for outcome in outcomes):
|
||||
raise ValueError("workflow outcomes must not contain blank values")
|
||||
if len(set(outcomes)) != len(outcomes):
|
||||
if len({outcome.strip() for outcome in outcomes}) != len(outcomes):
|
||||
raise ValueError("workflow outcomes must be unique")
|
||||
|
||||
|
||||
|
||||
@@ -520,8 +520,8 @@ def test_adapter_lowers_typed_and_untyped_interrupt_steps() -> None:
|
||||
|
||||
workflow = build_workflow_from_draft(draft)
|
||||
|
||||
review = workflow.nodes[0]
|
||||
legacy = workflow.nodes[1]
|
||||
review = next(node for node in workflow.nodes if node.id == "review")
|
||||
legacy = next(node for node in workflow.nodes if node.id == "legacy")
|
||||
assert isinstance(review, InterruptNode)
|
||||
assert review.request_schema == request_schema
|
||||
assert review.resume_schema == resume_schema
|
||||
@@ -570,6 +570,12 @@ def test_adapter_lowers_subgraph_step_without_resolving_artifact() -> None:
|
||||
assert child.workflow.version == 2
|
||||
assert child.input_schema == SchemaRef.model_validate(input_schema)
|
||||
assert child.output_schema == SchemaRef.model_validate(output_schema)
|
||||
assert child.model_dump(mode="json", by_alias=True)["input"] == [
|
||||
{"path": "state.topic", "target": "topic"}
|
||||
]
|
||||
assert child.model_dump(mode="json", by_alias=True)["output"] == [
|
||||
{"source": "report", "target": "state.report"}
|
||||
]
|
||||
assert child.outcomes == ["ok", "error"]
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts import (
|
||||
DraftWorkspaceStore,
|
||||
FileDraftWorkspaceStore,
|
||||
WorkflowDraftWorkspace,
|
||||
create_draft_workspace,
|
||||
get_draft_workspace,
|
||||
patch_draft_workspace,
|
||||
replace_validated_draft_document,
|
||||
summarize_draft_workspace,
|
||||
)
|
||||
|
||||
@@ -252,6 +254,44 @@ def test_patch_draft_workspace_rejects_invalid_patch_without_revision_bump(
|
||||
assert store.get_workspace("echo_draft").revision == 1
|
||||
|
||||
|
||||
def test_replace_validated_draft_document_isolates_persisted_draft() -> None:
|
||||
class ReferenceDraftWorkspaceStore(DraftWorkspaceStore):
|
||||
def __init__(self) -> None:
|
||||
self.workspace: WorkflowDraftWorkspace | None = None
|
||||
|
||||
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
self.workspace = workspace
|
||||
|
||||
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
|
||||
if self.workspace is None or self.workspace.id != workspace_id:
|
||||
raise KeyError(workspace_id)
|
||||
return self.workspace
|
||||
|
||||
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
|
||||
return [] if self.workspace is None else [self.workspace]
|
||||
|
||||
def delete_workspace(self, workspace_id: str) -> bool:
|
||||
if self.workspace is None or self.workspace.id != workspace_id:
|
||||
return False
|
||||
self.workspace = None
|
||||
return True
|
||||
|
||||
store = ReferenceDraftWorkspaceStore()
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
replacement = _draft()
|
||||
replacement["name"] = "replacement"
|
||||
|
||||
replace_validated_draft_document(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
draft=replacement,
|
||||
)
|
||||
replacement["name"] = "mutated_after_save"
|
||||
|
||||
assert store.get_workspace("echo_draft").draft["name"] == "replacement"
|
||||
|
||||
|
||||
def test_get_draft_workspace_includes_full_draft_only_when_requested(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
@@ -906,10 +906,12 @@ async def test_create_empty_draft_workspace_reports_duplicate_conflict(
|
||||
"contract",
|
||||
[
|
||||
{"input_schema": cast(Any, [])},
|
||||
{"input_schema": {"type": 5}},
|
||||
{"outcomes": ()},
|
||||
{"outcomes": cast(Any, (1,))},
|
||||
{"outcomes": ("ok", " ")},
|
||||
{"outcomes": ("ok", "ok")},
|
||||
{"outcomes": ("ok", " ok ")},
|
||||
],
|
||||
)
|
||||
async def test_create_empty_draft_workspace_rejects_invalid_contract_before_mutation(
|
||||
@@ -4594,6 +4596,15 @@ async def test_set_workflow_output_bindings_validates_root_literal_complete_sche
|
||||
],
|
||||
r"bindings\[0\]\.target '\.' already has an incompatible schema",
|
||||
),
|
||||
(
|
||||
[
|
||||
InputPathBinding(
|
||||
path=GraphSourcePath.context("prior_outcome"),
|
||||
target=LocalPath.root(),
|
||||
)
|
||||
],
|
||||
r"bindings\[0\]\.path 'context\.prior_outcome' cannot target '\.'",
|
||||
),
|
||||
(
|
||||
[InputValueBinding(target=LocalPath.root(), value="not-an-object")],
|
||||
r"bindings\[0\]\.value for root target must be an object",
|
||||
@@ -4605,7 +4616,9 @@ async def test_set_workflow_output_bindings_rejects_without_mutation(
|
||||
bindings: list[InputBinding],
|
||||
message: str,
|
||||
) -> None:
|
||||
workspace_id = f"invalid_output_{abs(hash(message))}"
|
||||
# Pytest gives every parameter case an isolated tmp_path, so a stable id is
|
||||
# sufficient and keeps failure artifacts reproducible across processes.
|
||||
workspace_id = "invalid_output"
|
||||
draft_api, _service, authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / workspace_id),
|
||||
register_echo=True,
|
||||
|
||||
@@ -888,6 +888,40 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
|
||||
assert call["bind_outputs"] == {"value": "state.value"}
|
||||
|
||||
|
||||
def test_wf_draft_add_capability_rejects_outcome_without_source_step(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class FakeHandlers:
|
||||
async def add_step_from_capability(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("handler must not be called")
|
||||
|
||||
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||
monkeypatch.setattr(
|
||||
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"capability",
|
||||
"workspace",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"call",
|
||||
"--capability",
|
||||
"demo.call",
|
||||
"--from-outcome",
|
||||
"error",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "--from-outcome requires --from-step" in result.output
|
||||
|
||||
|
||||
def test_wf_draft_update_capability_builds_presence_aware_patch(monkeypatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
@@ -1503,6 +1537,23 @@ def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
|
||||
"decision=state.other_decision",
|
||||
],
|
||||
)
|
||||
malformed_request_source = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"interrupt",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"review",
|
||||
"--kind",
|
||||
"review",
|
||||
"--request",
|
||||
"not_a_graph_source=items",
|
||||
],
|
||||
)
|
||||
missing_collect_target = runner.invoke(
|
||||
app,
|
||||
[
|
||||
@@ -1522,6 +1573,27 @@ def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
|
||||
"collect",
|
||||
],
|
||||
)
|
||||
collect_target_without_collect = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"draft",
|
||||
"add",
|
||||
"foreach",
|
||||
"ws",
|
||||
"--revision",
|
||||
"1",
|
||||
"--step",
|
||||
"each",
|
||||
"--over",
|
||||
"state.items",
|
||||
"--as",
|
||||
"item",
|
||||
"--item-error",
|
||||
"skip",
|
||||
"--collect-to",
|
||||
"state.errors",
|
||||
],
|
||||
)
|
||||
end_route = runner.invoke(
|
||||
app,
|
||||
[
|
||||
@@ -1551,11 +1623,21 @@ def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
|
||||
assert "duplicate --resume" in duplicate_resume.output
|
||||
assert "--bind-output" not in duplicate_resume.output
|
||||
assert "Traceback" not in duplicate_resume.output
|
||||
assert malformed_request_source.exit_code == 2
|
||||
assert "--request source 'not_a_graph_source'" in malformed_request_source.output
|
||||
assert "expected" in malformed_request_source.output
|
||||
assert "GRAPH_SOURCE=LOCAL_TARGET" in malformed_request_source.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 collect_target_without_collect.exit_code == 2
|
||||
assert (
|
||||
"--collect-to requires --item-error collect"
|
||||
in collect_target_without_collect.output
|
||||
)
|
||||
assert "Traceback" not in collect_target_without_collect.output
|
||||
assert end_route.exit_code == 2
|
||||
assert "No such option" in end_route.output
|
||||
assert "--route" in end_route.output
|
||||
@@ -2214,11 +2296,11 @@ def test_wf_draft_set_input_clear_sends_empty_binding_list(monkeypatch) -> None:
|
||||
([], "provide --map/--value, --bindings-file, or --clear"),
|
||||
(
|
||||
["--bindings-file", "bindings.json", "--map", "input.x=x"],
|
||||
"cannot be combined with --map or",
|
||||
"mutually exclusive",
|
||||
),
|
||||
(
|
||||
["--clear", "--value", "x=null"],
|
||||
"cannot be combined with --map or",
|
||||
"mutually exclusive",
|
||||
),
|
||||
(
|
||||
["--merge", "--value", "x=null"],
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
@@ -68,7 +69,9 @@ def test_update_capability_step_request_preserves_field_presence_and_binding_typ
|
||||
{"unknown": "field"},
|
||||
],
|
||||
)
|
||||
def test_update_capability_step_request_rejects_invalid_patch(update) -> None:
|
||||
def test_update_capability_step_request_rejects_invalid_patch(
|
||||
update: dict[str, Any],
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateCapabilityStepRequest.model_validate(
|
||||
{
|
||||
@@ -120,6 +123,22 @@ def test_add_step_from_capability_request_rejects_both_input_forms() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_add_step_from_capability_request_rejects_explicit_null_input_form() -> None:
|
||||
with pytest.raises(ValidationError, match="mutually exclusive"):
|
||||
AddStepFromCapabilityRequest.model_validate(
|
||||
{
|
||||
"workspace_id": "report",
|
||||
"revision": 3,
|
||||
"step_id": "publish",
|
||||
"capability_name": "local.report.publish",
|
||||
"input_map": None,
|
||||
"input_bindings": [
|
||||
{"value": "markdown", "target": "request.format"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_surface_rejects_unknown_draft_route_outcome_when_spec_is_known(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from wf_transport_rpc_http.app import create_rpc_app
|
||||
from wf_transport_rpc_http.models import (
|
||||
AddDraftStepParams,
|
||||
AddStepFromCapabilityParams,
|
||||
SetDraftContractParams,
|
||||
UpdateCapabilityStepParams,
|
||||
)
|
||||
|
||||
@@ -111,6 +112,17 @@ def test_add_step_from_capability_params_reject_both_input_forms() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_set_draft_contract_params_reject_whitespace_duplicate_outcomes() -> None:
|
||||
with pytest.raises(ValidationError, match="unique"):
|
||||
SetDraftContractParams.model_validate(
|
||||
{
|
||||
"workspace_id": "report",
|
||||
"revision": 1,
|
||||
"outcomes": ["ok", " ok "],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_rpc_health_and_capability_methods(tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
app = create_rpc_app(server)
|
||||
@@ -473,6 +485,14 @@ async def test_rpc_draft_workspace_lifecycle_methods(tmp_path) -> None:
|
||||
"outcomes": ["ok", "ok"],
|
||||
},
|
||||
),
|
||||
(
|
||||
"workflow.draft_workspaces.set_contract",
|
||||
{
|
||||
"workspace_id": "rpc_control",
|
||||
"revision": 1,
|
||||
"outcomes": ["ok", " ok "],
|
||||
},
|
||||
),
|
||||
(
|
||||
"workflow.draft_workspaces.set_start",
|
||||
{"workspace_id": "rpc_control", "revision": 1, "step_id": " "},
|
||||
@@ -1077,11 +1097,20 @@ async def test_rpc_set_step_output_bindings_rejects_malformed_binding(
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.create_from_capability",
|
||||
{
|
||||
"workspace_id": "valid_ws",
|
||||
"capability_name": "wf.std.constant",
|
||||
"name": "valid",
|
||||
},
|
||||
)
|
||||
rejected = await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.set_step_output_bindings",
|
||||
{
|
||||
"workspace_id": "missing_ws",
|
||||
"workspace_id": "valid_ws",
|
||||
"revision": 1,
|
||||
"step_id": "call",
|
||||
"bindings": [binding],
|
||||
@@ -1106,11 +1135,20 @@ async def test_rpc_set_step_input_bindings_rejects_malformed_union(
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.create_from_capability",
|
||||
{
|
||||
"workspace_id": "valid_ws",
|
||||
"capability_name": "wf.std.constant",
|
||||
"name": "valid",
|
||||
},
|
||||
)
|
||||
rejected = await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
{
|
||||
"workspace_id": "missing_ws",
|
||||
"workspace_id": "valid_ws",
|
||||
"revision": 1,
|
||||
"step_id": "call",
|
||||
"bindings": [binding],
|
||||
@@ -1229,11 +1267,20 @@ async def test_rpc_set_workflow_output_bindings_rejects_malformed_binding(
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.create_from_capability",
|
||||
{
|
||||
"workspace_id": "valid_ws",
|
||||
"capability_name": "wf.std.constant",
|
||||
"name": "valid",
|
||||
},
|
||||
)
|
||||
rejected = await _rpc(
|
||||
client,
|
||||
"workflow.draft_workspaces.set_workflow_output_bindings",
|
||||
{
|
||||
"workspace_id": "missing_ws",
|
||||
"workspace_id": "valid_ws",
|
||||
"revision": 1,
|
||||
"bindings": [binding],
|
||||
},
|
||||
@@ -1574,7 +1621,7 @@ async def test_rpc_draft_workspace_add_typed_step_round_trip(tmp_path) -> None:
|
||||
)
|
||||
|
||||
assert added["result"]["revision"] == created["result"]["revision"] + 1
|
||||
assert added["result"]["status"] in {"valid", "invalid"}
|
||||
assert added["result"]["status"] == "valid"
|
||||
assert "error" in malformed
|
||||
assert fetched["result"]["revision"] == added["result"]["revision"]
|
||||
assert "bad" not in fetched["result"]["draft"]["steps"]
|
||||
|
||||
Reference in New Issue
Block a user