fix: preserve editable remote nodes without snapshots

This commit is contained in:
lda
2026-08-31 01:35:18 +07:00 Verified
parent 370e1bd57c
commit 870fa65f0a
2 changed files with 98 additions and 4 deletions
+51 -4
View File
@@ -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)},
}
+47
View File
@@ -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)