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 ## Draft data-shaping parity
- [x] `wf draft bind` rejects nested node-local targets such as - [x] `wf draft bind` accepts nested node-local targets such as
`local.report.title`, even though canonical `LocalPath`, the runtime binding `local.report.title`, consistently with canonical `LocalPath`, the runtime
resolver, and `WorkflowBuilder` support nested local paths. binding resolver, and `WorkflowBuilder`.
- [x] Capability-step authoring persists nested input targets but silently skips - [x] Capability-step authoring projects workflow input/state schemas for nested
workflow input/state schema projection when a target has more than one path local targets while preserving their canonical binding paths.
segment.
- [x] An atomic API/RPC/CLI helper assembles one structured node input from - [x] An atomic API/RPC/CLI helper assembles one structured node input from
multiple graph paths. Canonical replacement accepts several bindings in one multiple graph paths. Canonical replacement accepts several bindings in one
revision-checked edit without an intermediate state object or raw JSON Patch. 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 mapping that validates against the complete declared `output_schema`. The
operation does not replace or infer the root output schema. 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 ## Path Source And Schema Rules
For `input.*` and `state.*` path bindings: For `input.*` and `state.*` path bindings:
+6 -1
View File
@@ -681,6 +681,11 @@ class WorkflowDraftAuthoringApi:
for index, binding in enumerate(bindings): for index, binding in enumerate(bindings):
target_parts = binding.target.parts target_parts = binding.target.parts
if isinstance(binding, InputPathBinding): 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) source_schema = source_schemas.get(index)
if source_schema is None: if source_schema is None:
if not schema_path_exists(projected, target_parts): if not schema_path_exists(projected, target_parts):
@@ -1082,7 +1087,7 @@ class WorkflowDraftAuthoringApi:
if value is not None if value is not None
} }
if metadata: if metadata:
CapabilityStepUpdate.model_validate(metadata) _ = CapabilityStepUpdate.model_validate(metadata)
spec = self.context.specs.get_qualified_spec(capability_name) spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = ( output_schema = (
+12 -3
View File
@@ -4,6 +4,8 @@ from collections.abc import Mapping, Sequence
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
from jsonschema import Draft202012Validator, SchemaError
from wf_artifacts import ( from wf_artifacts import (
DraftWorkspaceStore, DraftWorkspaceStore,
compile_workflow_draft, 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]: 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): if not isinstance(value, dict):
raise ValueError(f"{field_name} must be a JSON object") 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]: 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") raise ValueError("workflow outcomes must contain at least one value")
if any(not isinstance(value, str) or not value.strip() for value in values): if any(not isinstance(value, str) or not value.strip() for value in values):
raise ValueError("workflow outcomes must not contain blank 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") raise ValueError("workflow outcomes must be unique")
return values return values
+3 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time import time
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from copy import deepcopy
from typing import Any from typing import Any
from wf_artifacts.drafts import ( from wf_artifacts.drafts import (
@@ -121,10 +122,11 @@ def replace_validated_draft_document(
WorkflowDraft.model_validate(draft) WorkflowDraft.model_validate(draft)
if draft == workspace.draft: if draft == workspace.draft:
return summarize_draft_workspace(workspace) return summarize_draft_workspace(workspace)
stored_draft = deepcopy(draft)
next_workspace = workspace.model_copy( next_workspace = workspace.model_copy(
update={ update={
"revision": workspace.revision + 1, "revision": workspace.revision + 1,
"draft": draft, "draft": stored_draft,
"updated_at_epoch_ms": _now_ms(), "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` `--input state.title=report.title --input state.summary=report.summary`
`--bind-output title=state.title --bind-output summary=state.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 convenience_input_selected = input_mapping is not None or input_value is not None
if bindings_file is not None and convenience_input_selected: if bindings_file is not None and convenience_input_selected:
raise typer.BadParameter( raise typer.BadParameter(
@@ -378,6 +380,8 @@ def add_foreach_step(
raise typer.BadParameter( raise typer.BadParameter(
"--max-active and --max-outstanding require --mode concurrent" "--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: try:
concurrent_options: dict[str, int] = {} concurrent_options: dict[str, int] = {}
if max_active is not None: if max_active is not None:
+8 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
import typer import typer
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
from wf_api.surface import RouteSource from wf_api.draft_authoring import RouteSource
from wf_core.models.steps import ( from wf_core.models.steps import (
InputBinding, InputBinding,
InputPathBinding, InputPathBinding,
@@ -81,6 +81,13 @@ def _parse_step_input_map_flags(
expected="GRAPH_SOURCE=LOCAL_TARGET", expected="GRAPH_SOURCE=LOCAL_TARGET",
) )
for source, target in parsed.items(): 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."): if target.startswith("local."):
bare_target = target.removeprefix("local.") bare_target = target.removeprefix("local.")
raise typer.BadParameter( raise typer.BadParameter(
+1 -3
View File
@@ -445,9 +445,7 @@ def set_step_input(
if selected_modes == 0: if selected_modes == 0:
raise typer.BadParameter("provide --map/--value, --bindings-file, or --clear") raise typer.BadParameter("provide --map/--value, --bindings-file, or --clear")
if selected_modes > 1: if selected_modes > 1:
raise typer.BadParameter( raise typer.BadParameter("input modes are mutually exclusive")
"--bindings-file and --clear cannot be combined with --map or --value"
)
if merge and (literal_values or has_file or clear): if merge and (literal_values or has_file or clear):
raise typer.BadParameter( raise typer.BadParameter(
"--merge is supported only for compatibility map-only edits" "--merge is supported only for compatibility map-only edits"
+1 -1
View File
@@ -364,7 +364,7 @@ class AddStepFromCapabilityRequest(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def reject_both_input_forms(self) -> Self: 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") raise ValueError("input_map and input_bindings are mutually exclusive")
return self return self
+4
View File
@@ -4,6 +4,7 @@ from .app import create_rpc_app
from .client import RpcWorkflowApiClient from .client import RpcWorkflowApiClient
from .errors import WorkflowRpcError from .errors import WorkflowRpcError
from .models import ( from .models import (
AddDraftStepParams,
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
AdminEmptyParams, AdminEmptyParams,
BindDraftParams, BindDraftParams,
@@ -37,6 +38,7 @@ from .models import (
RemoveDraftRouteParams, RemoveDraftRouteParams,
RemoveDraftStepParams, RemoveDraftStepParams,
ResumeRunParams, ResumeRunParams,
RouteSourceParams,
SaveArtifactParams, SaveArtifactParams,
SaveDeploymentParams, SaveDeploymentParams,
SetDraftContractParams, SetDraftContractParams,
@@ -58,6 +60,7 @@ from .models import (
) )
__all__ = [ __all__ = [
"AddDraftStepParams",
"AddStepFromCapabilityParams", "AddStepFromCapabilityParams",
"AdminEmptyParams", "AdminEmptyParams",
"BindDraftParams", "BindDraftParams",
@@ -90,6 +93,7 @@ __all__ = [
"RemoveDraftBindingParams", "RemoveDraftBindingParams",
"RemoveDraftRouteParams", "RemoveDraftRouteParams",
"RemoveDraftStepParams", "RemoveDraftStepParams",
"RouteSourceParams",
"ResumeRunParams", "ResumeRunParams",
"SaveArtifactParams", "SaveArtifactParams",
"SaveDeploymentParams", "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") raise ValueError("workflow outcomes must contain at least one value")
if any(not outcome.strip() for outcome in outcomes): if any(not outcome.strip() for outcome in outcomes):
raise ValueError("workflow outcomes must not contain blank values") 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") 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) workflow = build_workflow_from_draft(draft)
review = workflow.nodes[0] review = next(node for node in workflow.nodes if node.id == "review")
legacy = workflow.nodes[1] legacy = next(node for node in workflow.nodes if node.id == "legacy")
assert isinstance(review, InterruptNode) assert isinstance(review, InterruptNode)
assert review.request_schema == request_schema assert review.request_schema == request_schema
assert review.resume_schema == resume_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.workflow.version == 2
assert child.input_schema == SchemaRef.model_validate(input_schema) assert child.input_schema == SchemaRef.model_validate(input_schema)
assert child.output_schema == SchemaRef.model_validate(output_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"] assert child.outcomes == ["ok", "error"]
+40
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from wf_artifacts import ( from wf_artifacts import (
DraftWorkspaceStore,
FileDraftWorkspaceStore, FileDraftWorkspaceStore,
WorkflowDraftWorkspace, WorkflowDraftWorkspace,
create_draft_workspace, create_draft_workspace,
get_draft_workspace, get_draft_workspace,
patch_draft_workspace, patch_draft_workspace,
replace_validated_draft_document,
summarize_draft_workspace, 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 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: def test_get_draft_workspace_includes_full_draft_only_when_requested(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path) store = FileDraftWorkspaceStore(tmp_path)
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft()) 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", "contract",
[ [
{"input_schema": cast(Any, [])}, {"input_schema": cast(Any, [])},
{"input_schema": {"type": 5}},
{"outcomes": ()}, {"outcomes": ()},
{"outcomes": cast(Any, (1,))}, {"outcomes": cast(Any, (1,))},
{"outcomes": ("ok", " ")}, {"outcomes": ("ok", " ")},
{"outcomes": ("ok", "ok")}, {"outcomes": ("ok", "ok")},
{"outcomes": ("ok", " ok ")},
], ],
) )
async def test_create_empty_draft_workspace_rejects_invalid_contract_before_mutation( 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", 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")], [InputValueBinding(target=LocalPath.root(), value="not-an-object")],
r"bindings\[0\]\.value for root target must be 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], bindings: list[InputBinding],
message: str, message: str,
) -> None: ) -> 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( draft_api, _service, authoring = _draft_api(
FileWorkflowArtifactStore(tmp_path / workspace_id), FileWorkflowArtifactStore(tmp_path / workspace_id),
register_echo=True, 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"} 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: def test_wf_draft_update_capability_builds_presence_aware_patch(monkeypatch) -> None:
calls: list[dict[str, Any]] = [] 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", "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( missing_collect_target = runner.invoke(
app, app,
[ [
@@ -1522,6 +1573,27 @@ def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
"collect", "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( end_route = runner.invoke(
app, 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 "duplicate --resume" in duplicate_resume.output
assert "--bind-output" not in duplicate_resume.output assert "--bind-output" not in duplicate_resume.output
assert "Traceback" 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 missing_collect_target.exit_code == 2
assert ( assert (
"collect item error policy requires collect_to" in missing_collect_target.output "collect item error policy requires collect_to" in missing_collect_target.output
) )
assert "Traceback" not 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 end_route.exit_code == 2
assert "No such option" in end_route.output assert "No such option" in end_route.output
assert "--route" 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"), ([], "provide --map/--value, --bindings-file, or --clear"),
( (
["--bindings-file", "bindings.json", "--map", "input.x=x"], ["--bindings-file", "bindings.json", "--map", "input.x=x"],
"cannot be combined with --map or", "mutually exclusive",
), ),
( (
["--clear", "--value", "x=null"], ["--clear", "--value", "x=null"],
"cannot be combined with --map or", "mutually exclusive",
), ),
( (
["--merge", "--value", "x=null"], ["--merge", "--value", "x=null"],
+20 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
@@ -68,7 +69,9 @@ def test_update_capability_step_request_preserves_field_presence_and_binding_typ
{"unknown": "field"}, {"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): with pytest.raises(ValidationError):
UpdateCapabilityStepRequest.model_validate( 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( def test_workflow_surface_rejects_unknown_draft_route_outcome_when_spec_is_known(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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 ( from wf_transport_rpc_http.models import (
AddDraftStepParams, AddDraftStepParams,
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
SetDraftContractParams,
UpdateCapabilityStepParams, 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: async def test_rpc_health_and_capability_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server) app = create_rpc_app(server)
@@ -473,6 +485,14 @@ async def test_rpc_draft_workspace_lifecycle_methods(tmp_path) -> None:
"outcomes": ["ok", "ok"], "outcomes": ["ok", "ok"],
}, },
), ),
(
"workflow.draft_workspaces.set_contract",
{
"workspace_id": "rpc_control",
"revision": 1,
"outcomes": ["ok", " ok "],
},
),
( (
"workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_start",
{"workspace_id": "rpc_control", "revision": 1, "step_id": " "}, {"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) app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: 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( rejected = await _rpc(
client, client,
"workflow.draft_workspaces.set_step_output_bindings", "workflow.draft_workspaces.set_step_output_bindings",
{ {
"workspace_id": "missing_ws", "workspace_id": "valid_ws",
"revision": 1, "revision": 1,
"step_id": "call", "step_id": "call",
"bindings": [binding], "bindings": [binding],
@@ -1106,11 +1135,20 @@ async def test_rpc_set_step_input_bindings_rejects_malformed_union(
app = create_rpc_app(server) app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: 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( rejected = await _rpc(
client, client,
"workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_input_bindings",
{ {
"workspace_id": "missing_ws", "workspace_id": "valid_ws",
"revision": 1, "revision": 1,
"step_id": "call", "step_id": "call",
"bindings": [binding], "bindings": [binding],
@@ -1229,11 +1267,20 @@ async def test_rpc_set_workflow_output_bindings_rejects_malformed_binding(
app = create_rpc_app(server) app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: 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( rejected = await _rpc(
client, client,
"workflow.draft_workspaces.set_workflow_output_bindings", "workflow.draft_workspaces.set_workflow_output_bindings",
{ {
"workspace_id": "missing_ws", "workspace_id": "valid_ws",
"revision": 1, "revision": 1,
"bindings": [binding], "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"]["revision"] == created["result"]["revision"] + 1
assert added["result"]["status"] in {"valid", "invalid"} assert added["result"]["status"] == "valid"
assert "error" in malformed assert "error" in malformed
assert fetched["result"]["revision"] == added["result"]["revision"] assert fetched["result"]["revision"] == added["result"]["revision"]
assert "bad" not in fetched["result"]["draft"]["steps"] assert "bad" not in fetched["result"]["draft"]["steps"]