fix: preserve editable remote nodes without snapshots
This commit is contained in:
@@ -221,11 +221,32 @@ def _seed_remote_node_defs(
|
|||||||
for requirement in artifact.required_capabilities:
|
for requirement in artifact.required_capabilities:
|
||||||
if requirement.kind != "node_spec":
|
if requirement.kind != "node_spec":
|
||||||
continue
|
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())
|
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(
|
builder.seeded_node_defs.setdefault(
|
||||||
name,
|
name,
|
||||||
NodeDef(
|
NodeDef(
|
||||||
@@ -235,3 +256,29 @@ def _seed_remote_node_defs(
|
|||||||
outcomes=outcomes_by_node.get(name, ["ok"]),
|
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)},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
@pytest.mark.asyncio
|
||||||
async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None:
|
async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None:
|
||||||
port = FakePort()
|
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 inspect == {"artifact_id": "report", "version": 2}
|
||||||
assert saved.ref == ArtifactRef("report", 2)
|
assert saved.ref == ArtifactRef("report", 2)
|
||||||
assert str(saved.workflow.output[0].target) == "value"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user