fix: preserve canonical bindings in focused binds

This commit is contained in:
lda
2026-07-29 09:14:11 +07:00 Verified
parent 69ac8e2406
commit 7fbc7eabba
6 changed files with 471 additions and 68 deletions
+201
View File
@@ -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")
+99
View File
@@ -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