fix via review findings

This commit is contained in:
lda
2026-07-28 16:13:20 +07:00 Verified
parent 75874eac4a
commit 671bdbb3b6
17 changed files with 267 additions and 27 deletions
+5 -6
View File
@@ -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:
+6 -1
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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(),
}
)
+4
View File
@@ -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:
+8 -1
View File
@@ -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(
+1 -3
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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")
+8 -2
View File
@@ -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"]
+40
View File
@@ -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())
+14 -1
View File
@@ -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,
+84 -2
View File
@@ -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"],
+20 -1
View File
@@ -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:
+51 -4
View File
@@ -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"]