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")