fix: validate workflow output target references
This commit is contained in:
@@ -170,6 +170,9 @@ implicit same-name state fallback. Use the map tool only for compatibility:
|
|||||||
`set_workflow_output_map` cannot preserve canonical path/value order, literals,
|
`set_workflow_output_map` cannot preserve canonical path/value order, literals,
|
||||||
or repeated-source fan-out.
|
or repeated-source fan-out.
|
||||||
|
|
||||||
|
The equivalent JSON-RPC operation is
|
||||||
|
`workflow.draft_workspaces.set_workflow_output_bindings`.
|
||||||
|
|
||||||
Advanced workspace tools:
|
Advanced workspace tools:
|
||||||
|
|
||||||
- `wf.workflow.list_draft_workspaces`: find mutable draft sessions.
|
- `wf.workflow.list_draft_workspaces`: find mutable draft sessions.
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ For the copy-less wrapper authoring path, use:
|
|||||||
`wf.workflow.set_workflow_output_bindings`, their compatibility map adapters,
|
`wf.workflow.set_workflow_output_bindings`, their compatibility map adapters,
|
||||||
and `wf.workflow.set_draft_route` to fix low-confidence hints or explicit
|
and `wf.workflow.set_draft_route` to fix low-confidence hints or explicit
|
||||||
`missing_decisions`.
|
`missing_decisions`.
|
||||||
|
JSON-RPC clients use
|
||||||
|
`workflow.draft_workspaces.set_workflow_output_bindings` for the same
|
||||||
|
canonical replacement.
|
||||||
4. `wf.workflow.validate_draft_workspace` to refresh diagnostics.
|
4. `wf.workflow.validate_draft_workspace` to refresh diagnostics.
|
||||||
5. `wf.workflow.create_wrapper_from_workspace` to save the wrapper artifact.
|
5. `wf.workflow.create_wrapper_from_workspace` to save the wrapper artifact.
|
||||||
6. `wf.workflow.call_capability` with `workflow.<artifact_id>.v<version>` to
|
6. `wf.workflow.call_capability` with `workflow.<artifact_id>.v<version>` to
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ def _draft_schema(draft: Mapping[str, Any], key: str) -> dict[str, Any]:
|
|||||||
return deepcopy(value)
|
return deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
def _overlapping_input_targets_error(
|
def _overlapping_input_binding_targets_error(
|
||||||
bindings: Sequence[InputBinding],
|
bindings: Sequence[InputBinding],
|
||||||
) -> ValueError:
|
) -> ValueError:
|
||||||
"""Describe the first overlapping binding pair with stable input indexes."""
|
"""Describe the first overlapping input-shaped target pair."""
|
||||||
for left_index, left in enumerate(bindings):
|
for left_index, left in enumerate(bindings):
|
||||||
for right_index in range(left_index + 1, len(bindings)):
|
for right_index in range(left_index + 1, len(bindings)):
|
||||||
right = bindings[right_index]
|
right = bindings[right_index]
|
||||||
@@ -137,22 +137,6 @@ def _workflow_source_schema(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _overlapping_workflow_output_targets_error(
|
|
||||||
bindings: Sequence[InputBinding],
|
|
||||||
) -> ValueError:
|
|
||||||
"""Describe the first overlapping public-output target pair."""
|
|
||||||
for left_index, left in enumerate(bindings):
|
|
||||||
for right_index in range(left_index + 1, len(bindings)):
|
|
||||||
right = bindings[right_index]
|
|
||||||
if paths_overlap(left.target, right.target):
|
|
||||||
return ValueError(
|
|
||||||
f"bindings[{left_index}].target {str(left.target)!r} "
|
|
||||||
f"overlaps bindings[{right_index}].target "
|
|
||||||
f"{str(right.target)!r}"
|
|
||||||
)
|
|
||||||
raise AssertionError("overlap error requested without overlapping targets")
|
|
||||||
|
|
||||||
|
|
||||||
def _step_input_bindings_patch(
|
def _step_input_bindings_patch(
|
||||||
*,
|
*,
|
||||||
workspace: WorkflowDraftWorkspace,
|
workspace: WorkflowDraftWorkspace,
|
||||||
@@ -459,7 +443,7 @@ class WorkflowDraftAuthoringApi:
|
|||||||
|
|
||||||
targets = [binding.target for binding in bindings]
|
targets = [binding.target for binding in bindings]
|
||||||
if has_overlapping_paths(targets):
|
if has_overlapping_paths(targets):
|
||||||
raise _overlapping_input_targets_error(bindings)
|
raise _overlapping_input_binding_targets_error(bindings)
|
||||||
|
|
||||||
projected_input = _draft_schema(workspace.draft, "input_schema")
|
projected_input = _draft_schema(workspace.draft, "input_schema")
|
||||||
projected_state = _draft_schema(workspace.draft, "state_schema")
|
projected_state = _draft_schema(workspace.draft, "state_schema")
|
||||||
@@ -572,7 +556,7 @@ class WorkflowDraftAuthoringApi:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if has_overlapping_paths(binding.target for binding in bindings):
|
if has_overlapping_paths(binding.target for binding in bindings):
|
||||||
raise _overlapping_workflow_output_targets_error(bindings)
|
raise _overlapping_input_binding_targets_error(bindings)
|
||||||
|
|
||||||
projected = output_schema
|
projected = output_schema
|
||||||
for index, binding in enumerate(bindings):
|
for index, binding in enumerate(bindings):
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ def project_schema_path_to_schema_path(
|
|||||||
)
|
)
|
||||||
leaf = target_parts[-1]
|
leaf = target_parts[-1]
|
||||||
if leaf in properties:
|
if leaf in properties:
|
||||||
|
# Validate an existing target reference against the target document
|
||||||
|
# before source definitions are merged. Otherwise an unresolved target
|
||||||
|
# could be silently repaired by an equivalent source-side definition.
|
||||||
|
_resolve_local_reference(
|
||||||
|
projected,
|
||||||
|
properties[leaf],
|
||||||
|
label=".".join(target_parts),
|
||||||
|
)
|
||||||
if allow_existing_equivalent and properties[leaf] == source_value:
|
if allow_existing_equivalent and properties[leaf] == source_value:
|
||||||
_merge_definition_block(projected, source_schema, "$defs")
|
_merge_definition_block(projected, source_schema, "$defs")
|
||||||
_merge_definition_block(projected, source_schema, "definitions")
|
_merge_definition_block(projected, source_schema, "definitions")
|
||||||
|
|||||||
@@ -481,6 +481,27 @@ def test_schema_path_exists_follows_local_defs() -> None:
|
|||||||
assert schema_path_exists(schema, ("report", "missing")) is False
|
assert schema_path_exists(schema, ("report", "missing")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_schema_path_rejects_unresolved_equivalent_target_reference() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match=r"unresolved reference '#/\$defs/Report'",
|
||||||
|
):
|
||||||
|
project_schema_path_to_schema_path(
|
||||||
|
target_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
||||||
|
},
|
||||||
|
source_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
||||||
|
"$defs": {"Report": {"type": "object", "properties": {}}},
|
||||||
|
},
|
||||||
|
source_parts=("report",),
|
||||||
|
target_parts=("report",),
|
||||||
|
allow_existing_equivalent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_project_schema_path_rejects_missing_nested_source() -> None:
|
def test_project_schema_path_rejects_missing_nested_source() -> None:
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
|
|||||||
Reference in New Issue
Block a user