fix: validate workflow output target references

This commit is contained in:
lda
2026-07-26 21:46:42 +07:00 Verified
parent 0f11773611
commit b3ac92d8cb
5 changed files with 39 additions and 20 deletions
+3
View File
@@ -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.
+3
View File
@@ -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
+4 -20
View File
@@ -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):
+8
View File
@@ -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")
+21
View File
@@ -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,