fix: preserve canonical bindings in focused binds
This commit is contained in:
@@ -1299,6 +1299,40 @@ async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) ->
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_input_map_merge_preserves_literal_prefix(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, _service, _authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "input_merge_literal_prefix")
|
||||
)
|
||||
draft = _echo_draft()
|
||||
draft["steps"]["echo"]["input"] = [
|
||||
{"value": "seed", "target": "fallback"},
|
||||
{"path": "input.text", "target": "text"},
|
||||
]
|
||||
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
||||
|
||||
result = await api.set_step_input_map(
|
||||
workspace_id="echo_ws",
|
||||
revision=1,
|
||||
step_id="echo",
|
||||
input_map={"input.extra": "extra"},
|
||||
merge=True,
|
||||
)
|
||||
|
||||
fetched = await api.get_draft_workspace(
|
||||
workspace_id="echo_ws",
|
||||
include_draft=True,
|
||||
)
|
||||
assert result["revision"] == 2
|
||||
assert fetched["draft"]["steps"]["echo"]["input"] == [
|
||||
{"value": "seed", "target": "fallback"},
|
||||
{"path": "input.text", "target": "text"},
|
||||
{"path": "input.extra", "target": "extra"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_input_map_merge_rejects_canonical_source_fan_out(
|
||||
tmp_path: Path,
|
||||
@@ -1799,6 +1833,45 @@ async def test_bind_draft_workflow_input_to_step_input_can_repeat(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_draft_input_preserves_literals_and_unrelated_fan_out(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, service, authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_inputs"),
|
||||
register_echo=True,
|
||||
)
|
||||
service.register_specs("demo.personal", _structured_report)
|
||||
draft = _structured_report_draft()
|
||||
draft["state_schema"] = {
|
||||
"type": "object",
|
||||
"properties": {"shared": {"type": "string"}},
|
||||
}
|
||||
draft["steps"]["report"]["input"] = [
|
||||
{"path": "state.shared", "target": "request.title"},
|
||||
{"path": "state.shared", "target": "request.format"},
|
||||
{"value": "audit", "target": "audit.note"},
|
||||
]
|
||||
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
||||
|
||||
result = await authoring.bind_draft(
|
||||
workspace_id="report",
|
||||
revision=1,
|
||||
step_id="report",
|
||||
source_path="input.body",
|
||||
target_path="local.request.body",
|
||||
)
|
||||
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
||||
|
||||
assert result["revision"] == 2
|
||||
assert workspace["draft"]["steps"]["report"]["input"] == [
|
||||
{"path": "state.shared", "target": "request.title"},
|
||||
{"path": "state.shared", "target": "request.format"},
|
||||
{"value": "audit", "target": "audit.note"},
|
||||
{"path": "input.body", "target": "request.body"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_draft_workflow_state_to_step_input_reuses_existing_schema(
|
||||
tmp_path: Path,
|
||||
@@ -2028,6 +2101,51 @@ async def test_bind_draft_lowers_nested_local_output_to_public_output(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_draft_output_preserves_unrelated_source_fan_out(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, service, authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_outputs"),
|
||||
register_echo=True,
|
||||
)
|
||||
service.register_specs("demo.personal", _nested_report)
|
||||
draft = _nested_report_draft()
|
||||
draft["state_schema"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"audit_title": {"type": "string"},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
draft["steps"]["render"]["output"] = [
|
||||
{"source": "report.title", "target": "state.report.title"},
|
||||
{"source": "report.title", "target": "state.report.audit_title"},
|
||||
]
|
||||
await api.create_draft_workspace(workspace_id="nested", draft=draft)
|
||||
|
||||
result = await authoring.bind_draft(
|
||||
workspace_id="nested",
|
||||
revision=1,
|
||||
step_id="render",
|
||||
source_path="local.report.markdown",
|
||||
target_path="state.report.markdown",
|
||||
)
|
||||
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
||||
|
||||
assert result["revision"] == 2
|
||||
assert workspace["draft"]["steps"]["render"]["output"] == [
|
||||
{"source": "report.title", "target": "state.report.title"},
|
||||
{"source": "report.title", "target": "state.report.audit_title"},
|
||||
{"source": "report.markdown", "target": "state.report.markdown"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_draft_rebinds_nested_local_public_output(tmp_path: Path) -> None:
|
||||
api, service, authoring = _draft_api(
|
||||
@@ -5059,6 +5177,48 @@ async def test_workflow_output_map_merge_rejects_requested_fan_out_source(
|
||||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_output_map_merge_checks_revision_before_ambiguity(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, _service, _authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "workflow_merge_stale_fan_out"),
|
||||
register_echo=True,
|
||||
)
|
||||
draft = {
|
||||
**_echo_draft(),
|
||||
"output": [
|
||||
{"path": "state.echoed", "target": "first"},
|
||||
{"path": "state.echoed", "target": "second"},
|
||||
],
|
||||
}
|
||||
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
||||
await api.set_draft_name(
|
||||
workspace_id="report",
|
||||
revision=1,
|
||||
name="report_v2",
|
||||
)
|
||||
before = await api.get_draft_workspace(
|
||||
workspace_id="report",
|
||||
include_draft=True,
|
||||
)
|
||||
|
||||
result = await api.set_workflow_output_map(
|
||||
workspace_id="report",
|
||||
revision=1,
|
||||
output_map={"state.echoed": "renamed"},
|
||||
merge=True,
|
||||
)
|
||||
|
||||
after = await api.get_draft_workspace(
|
||||
workspace_id="report",
|
||||
include_draft=True,
|
||||
)
|
||||
assert result["status"] == "conflict"
|
||||
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
||||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_output_map_merge_preserves_unrequested_fan_out_and_literals(
|
||||
tmp_path: Path,
|
||||
@@ -5621,6 +5781,47 @@ async def test_bind_draft_replaces_previous_public_output_for_local_field(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_draft_rejects_ambiguous_previous_public_output_fan_out(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, _service, authoring = _draft_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "bind_ambiguous_public_output"),
|
||||
register_echo=True,
|
||||
)
|
||||
draft = _echo_draft()
|
||||
draft["state_schema"] = {
|
||||
"type": "object",
|
||||
"properties": {"old": {"type": "string"}},
|
||||
}
|
||||
draft["output_schema"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first": {"type": "string"},
|
||||
"second": {"type": "string"},
|
||||
},
|
||||
}
|
||||
draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}]
|
||||
draft["output"] = [
|
||||
{"path": "state.old", "target": "first"},
|
||||
{"path": "state.old", "target": "second"},
|
||||
]
|
||||
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
||||
before = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
||||
|
||||
with pytest.raises(ValueError, match="multiple public output bindings"):
|
||||
await authoring.bind_draft(
|
||||
workspace_id="report",
|
||||
revision=1,
|
||||
step_id="echo",
|
||||
source_path="local.echoed",
|
||||
target_path="output.echoed",
|
||||
)
|
||||
|
||||
after = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
||||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_draft_binding_rejects_non_object_entries(tmp_path: Path) -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bad_bindings")
|
||||
|
||||
@@ -1397,6 +1397,83 @@ def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
||||
]
|
||||
|
||||
|
||||
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
rpc_methods: list[str] = []
|
||||
original_call = RpcClientTransport._call
|
||||
|
||||
async def recording_call(
|
||||
self: RpcClientTransport, method: str, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
rpc_methods.append(method)
|
||||
return await original_call(self, method, params)
|
||||
|
||||
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||
bindings_path = tmp_path / "workflow-output-bindings.json"
|
||||
bindings_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"path": "state.value", "target": "first"},
|
||||
{"path": "state.value", "target": "second"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
|
||||
created = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"create",
|
||||
"report",
|
||||
"--capability",
|
||||
"wf.std.constant",
|
||||
],
|
||||
)
|
||||
replaced = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"set-workflow-output",
|
||||
"report",
|
||||
"--revision",
|
||||
"1",
|
||||
"--bindings-file",
|
||||
str(bindings_path),
|
||||
],
|
||||
)
|
||||
rpc_methods.clear()
|
||||
|
||||
merged = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"set-workflow-output",
|
||||
"report",
|
||||
"--revision",
|
||||
"2",
|
||||
"--map",
|
||||
"state.value=renamed",
|
||||
"--merge",
|
||||
],
|
||||
)
|
||||
|
||||
assert created.exit_code == 0, created.output
|
||||
assert replaced.exit_code == 0, replaced.output
|
||||
assert merged.exit_code == 1
|
||||
assert "complete canonical binding list" in merged.output
|
||||
assert rpc_methods == ["workflow.draft_workspaces.set_workflow_output_map"]
|
||||
|
||||
|
||||
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
@@ -1636,6 +1713,28 @@ def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
||||
]
|
||||
assert rpc_methods.count("workflow.draft_workspaces.set_step_input_bindings") == 1
|
||||
|
||||
rpc_methods.clear()
|
||||
merged = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"set-input",
|
||||
"filter_ws",
|
||||
"--revision",
|
||||
"2",
|
||||
"--step",
|
||||
"call",
|
||||
"--map",
|
||||
"input.other=other",
|
||||
"--merge",
|
||||
],
|
||||
)
|
||||
|
||||
assert merged.exit_code == 1
|
||||
assert "complete canonical binding list" in merged.output
|
||||
assert rpc_methods == ["workflow.draft_workspaces.set_step_input_map"]
|
||||
|
||||
|
||||
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
||||
monkeypatch, tmp_path
|
||||
|
||||
Reference in New Issue
Block a user