diff --git a/src/wf_client/authoring.py b/src/wf_client/authoring.py index 1d7726c9..4c33f232 100644 --- a/src/wf_client/authoring.py +++ b/src/wf_client/authoring.py @@ -221,11 +221,32 @@ def _seed_remote_node_defs( for requirement in artifact.required_capabilities: if requirement.kind != "node_spec": continue - input_schema = requirement.input_schema_snapshot - output_schema = requirement.output_schema_snapshot - if not isinstance(input_schema, dict) or not isinstance(output_schema, dict): - continue name = str(requirement.capability_ref()) + node_uses = [ + node + for node in artifact.workflow.nodes + if isinstance(node, NodeUse) and node.node == name + ] + input_fields = { + field + for node in node_uses + for binding in node.input + if (field := _binding_root_field(binding.target)) is not None + } + output_fields = { + field + for node in node_uses + for binding in node.output + if (field := _binding_root_field(binding.source)) is not None + } + input_schema = _snapshot_or_permissive_schema( + requirement.input_schema_snapshot, + input_fields, + ) + output_schema = _snapshot_or_permissive_schema( + requirement.output_schema_snapshot, + output_fields, + ) builder.seeded_node_defs.setdefault( name, NodeDef( @@ -235,3 +256,29 @@ def _seed_remote_node_defs( outcomes=outcomes_by_node.get(name, ["ok"]), ), ) + + +def _binding_root_field(path: object) -> str | None: + """Return a local binding's first field, excluding whole-payload ``.``.""" + parts = getattr(path, "parts", ()) + if not parts: + return None + return parts[0] + + +def _snapshot_or_permissive_schema( + snapshot: object, + fields: set[str], +) -> dict[str, Any]: + """Use a saved snapshot or an unconstrained schema for its used fields. + + A missing server snapshot carries no type information. Declaring only the + fields already referenced by graph bindings lets local structural checks + proceed without inventing validation constraints for remote data. + """ + if isinstance(snapshot, dict): + return snapshot + return { + "type": "object", + "properties": {field: {} for field in sorted(fields)}, + } diff --git a/tests/wf_client/test_authoring.py b/tests/wf_client/test_authoring.py index 9e63ec47..ce1750ca 100644 --- a/tests/wf_client/test_authoring.py +++ b/tests/wf_client/test_authoring.py @@ -61,6 +61,37 @@ def valid_plan(version: int = 1) -> dict[str, Any]: } +def remote_plan_without_schema_snapshots(version: int = 1) -> dict[str, Any]: + payload = valid_plan(version) + payload["plan"]["nodes"] = [ + { + "id": "remote", + "type": "node", + "node": "app.default.remote", + "input": [], + "output": [], + }, + {"id": "done", "type": "end", "outcome": "ok"}, + ] + payload["plan"]["start"] = "remote" + payload["plan"]["edges"] = [ + {"from": "remote", "outcome": "ok", "to": "done"} + ] + payload["required_capabilities"] = [ + { + "ref": {"source": "app.default", "capability_key": "remote"}, + "kind": "node_spec", + "input_schema_hash": None, + "input_schema_snapshot": None, + "output_schema_hash": None, + "output_schema_snapshot": None, + "observed_concrete_source": None, + "observed_at_epoch_ms": None, + } + ] + return payload + + @pytest.mark.asyncio async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None: port = FakePort() @@ -118,3 +149,19 @@ async def test_edit_and_save_inspects_exact_saved_version() -> None: assert inspect == {"artifact_id": "report", "version": 2} assert saved.ref == ArtifactRef("report", 2) assert str(saved.workflow.output[0].target) == "value" + + +@pytest.mark.asyncio +async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None: + port = FakePort() + port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=1) + graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow( + "report", version=1 + ) + + result = graph.validate_local() + + assert result.ok is True + port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=2) + saved = await graph.save(version=2) + assert saved.ref == ArtifactRef("report", 2)