feat: replace workflow output bindings from cli

This commit is contained in:
lda
2026-07-26 21:26:06 +07:00 Verified
parent 54a32c7f41
commit b0a01a63b0
4 changed files with 582 additions and 29 deletions
+329 -3
View File
@@ -16,6 +16,9 @@ from wf_cli.commands.draft_options import (
parse_step_input_value_flags,
parse_step_output_binding_flags,
parse_step_output_bindings_file,
parse_workflow_output_binding_flags,
parse_workflow_output_bindings_file,
parse_workflow_output_value_flags,
route_source,
)
@@ -155,6 +158,107 @@ def test_draft_options_parse_step_input_bindings_file_validates_union(
parse_step_input_bindings_file(path)
def test_parse_workflow_output_flags_preserves_path_then_literal_order() -> None:
bindings = [
*parse_workflow_output_binding_flags(
[
"state.title=report.title",
"state.title=audit.title",
]
),
*parse_workflow_output_value_flags(['format="markdown"', "optional=null"]),
]
assert [binding.model_dump(mode="json") for binding in bindings] == [
{"path": "state.title", "target": "report.title"},
{"path": "state.title", "target": "audit.title"},
{"value": "markdown", "target": "format"},
{"value": None, "target": "optional"},
]
def test_parse_workflow_output_bindings_file_preserves_mixed_order(
tmp_path,
) -> None:
path = tmp_path / "workflow-output-bindings.json"
path.write_text(
json.dumps(
[
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
{"path": "input.audit", "target": "audit"},
]
),
encoding="utf-8",
)
bindings = parse_workflow_output_bindings_file(path)
assert [binding.model_dump(mode="json") for binding in bindings] == [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
{"path": "input.audit", "target": "audit"},
]
@pytest.mark.parametrize(
("parser", "values", "expected_text"),
[
(
parse_workflow_output_binding_flags,
["unknown.title=report.title"],
"graph source path",
),
(
parse_workflow_output_binding_flags,
["state.title=local.report.title"],
"rootless workflow-output path",
),
(
parse_workflow_output_value_flags,
['local.format="markdown"'],
"rootless workflow-output path",
),
(
parse_workflow_output_value_flags,
["format=not-json"],
"invalid JSON",
),
],
)
def test_parse_workflow_output_flags_report_compact_errors(
parser, values: list[str], expected_text: str
) -> None:
with pytest.raises(typer.BadParameter) as exc_info:
parser(values)
message = str(exc_info.value)
assert expected_text in message
assert "Traceback" not in message
@pytest.mark.parametrize(
("payload", "expected_text"),
[
({"path": "state.title", "target": "title"}, "list"),
(
[{"path": "state.title", "value": "x", "target": "title"}],
"validation errors",
),
],
)
def test_parse_workflow_output_bindings_file_rejects_invalid_payload(
tmp_path, payload, expected_text: str
) -> None:
path = tmp_path / "invalid-workflow-output-bindings.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(typer.BadParameter) as exc_info:
parse_workflow_output_bindings_file(path)
assert expected_text in str(exc_info.value)
def test_wf_help_lists_lifecycle_groups() -> None:
result = runner.invoke(app, ["--help"])
@@ -645,10 +749,15 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
assert "replace with no bindings" in output_help.lower()
assert "compatibility-only and potentially lossy" in output_help
assert "draft validate" in output_help
assert "replaces the full workflow output map" in workflow_output_help
assert "complete ordered workflow output binding list" in workflow_output_help
assert "GRAPH_SOURCE=OUTPUT_TARGET" in workflow_output_help
assert "OUTPUT_TARGET=JSON" in workflow_output_help
assert "ordered canonical JSON array" in workflow_output_help
assert "same-name state fallback" in workflow_output_help
assert "inspect --include-draft" in workflow_output_help
assert "draft.output" in workflow_output_help
assert "Use --merge only" in workflow_output_help
assert "GRAPH_SOURCE=OUTPUT_FIELD" in workflow_output_help
assert "output_schema fields are projected" in workflow_output_help
assert "compatibility-only and potentially lossy" in workflow_output_help
assert "draft validate" in workflow_output_help
@@ -2130,3 +2239,220 @@ def test_wf_draft_set_output_merge_keeps_compatibility_map_handler(monkeypatch)
"merge": True,
}
]
def test_wf_draft_set_workflow_output_replaces_with_canonical_bindings(
monkeypatch,
) -> None:
binding_calls: list[dict[str, Any]] = []
map_calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_workflow_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
binding_calls.append(kwargs)
return {"revision": 5, "status": "valid"}
async def set_workflow_output_map(self, **kwargs: Any) -> dict[str, Any]:
map_calls.append(kwargs)
return {"revision": 5, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
"--map",
"state.title=report.title",
"--map",
"state.title=audit.title",
"--value",
'format="markdown"',
],
)
assert result.exit_code == 0, result.output
assert map_calls == []
assert [
binding.model_dump(mode="json") for binding in binding_calls[0]["bindings"]
] == [
{"path": "state.title", "target": "report.title"},
{"path": "state.title", "target": "audit.title"},
{"value": "markdown", "target": "format"},
]
def test_wf_draft_set_workflow_output_replaces_from_bindings_file(
monkeypatch, tmp_path
) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_workflow_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 5, "status": "valid"}
path = tmp_path / "workflow-output-bindings.json"
path.write_text(
json.dumps(
[
{"value": "markdown", "target": "format"},
{"path": "state.title", "target": "report.title"},
]
),
encoding="utf-8",
)
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
"--bindings-file",
str(path),
],
)
assert result.exit_code == 0, result.output
assert [binding.model_dump(mode="json") for binding in calls[0]["bindings"]] == [
{"value": "markdown", "target": "format"},
{"path": "state.title", "target": "report.title"},
]
def test_wf_draft_set_workflow_output_clear_restores_fallback(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_workflow_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 5, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
"--clear",
],
)
assert result.exit_code == 0, result.output
assert calls[0]["bindings"] == []
@pytest.mark.parametrize(
("extra_args", "expected_error"),
[
([], "provide --map, --value, --bindings-file, or --clear"),
(
["--bindings-file", "bindings.json", "--map", "state.x=x"],
"--bindings-file is mutually exclusive",
),
(
["--bindings-file", "bindings.json", "--value", "x=1"],
"--bindings-file is mutually exclusive",
),
(
["--bindings-file", "bindings.json", "--clear"],
"--bindings-file is mutually exclusive",
),
(["--clear", "--map", "state.x=x"], "--clear is mutually exclusive"),
(["--clear", "--value", "x=1"], "--clear is mutually exclusive"),
(
["--merge", "--value", "x=1"],
"--merge is supported only for compatibility map-only edits",
),
(
["--merge", "--bindings-file", "bindings.json"],
"--merge is supported only for compatibility map-only edits",
),
(
["--merge", "--clear"],
"--merge is supported only for compatibility map-only edits",
),
],
)
def test_wf_draft_set_workflow_output_rejects_invalid_modes_before_context(
monkeypatch, extra_args: list[str], expected_error: str
) -> None:
monkeypatch.setattr(
"wf_cli.commands.drafts.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
*extra_args,
],
)
assert result.exit_code == 2
assert expected_error in " ".join(result.output.split())
assert "context loaded" not in result.output
def test_wf_draft_set_workflow_output_merge_keeps_compatibility_map_handler(
monkeypatch,
) -> None:
binding_calls: list[dict[str, Any]] = []
map_calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_workflow_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
binding_calls.append(kwargs)
return {"revision": 5, "status": "valid"}
async def set_workflow_output_map(self, **kwargs: Any) -> dict[str, Any]:
map_calls.append(kwargs)
return {"revision": 5, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
"--map",
"state.title=report.title",
"--merge",
],
)
assert result.exit_code == 0, result.output
assert binding_calls == []
assert map_calls == [
{
"workspace_id": "report_ws",
"revision": 4,
"output_map": {"state.title": "report.title"},
"merge": True,
}
]
+107 -1
View File
@@ -1248,9 +1248,110 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
]
def test_wf_draft_set_workflow_output_uses_rpc_target(monkeypatch, tmp_path) -> None:
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
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")
state_schema_path = tmp_path / "state-schema.json"
state_schema_path.write_text(
json.dumps(
{
"type": "object",
"properties": {"title": {"type": "string"}},
}
),
encoding="utf-8",
)
output_schema_path = tmp_path / "output-schema.json"
output_schema_path.write_text(
json.dumps(
{
"type": "object",
"properties": {"format": {"type": "string"}},
}
),
encoding="utf-8",
)
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"report",
"--name",
"report",
"--state-schema-file",
str(state_schema_path),
"--output-schema-file",
str(output_schema_path),
],
)
assert created.exit_code == 0, created.output
rpc_calls.clear()
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-workflow-output",
"report",
"--revision",
"1",
"--map",
"state.title=report.title",
"--value",
'format="markdown"',
],
)
assert result.exit_code == 0, result.output
assert rpc_calls == [
(
"workflow.draft_workspaces.set_workflow_output_bindings",
{
"workspace_id": "report",
"revision": 1,
"bindings": [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
],
},
)
]
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
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")
runner = CliRunner()
@@ -1277,6 +1378,7 @@ def test_wf_draft_set_workflow_output_uses_rpc_target(monkeypatch, tmp_path) ->
"1",
"--map",
"state.markdown=markdown",
"--merge",
],
)
inspected = runner.invoke(
@@ -1289,6 +1391,10 @@ def test_wf_draft_set_workflow_output_uses_rpc_target(monkeypatch, tmp_path) ->
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["output"] == [{"path": "state.markdown", "target": "markdown"}]
assert rpc_methods[-2:] == [
"workflow.draft_workspaces.set_workflow_output_map",
"workflow.draft_workspaces.get",
]
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None: