diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5a3d0f..a4d23cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ history. ### Fixed +- Writing a declared promoted widget before a legacy `proxyWidgets` slot no + longer shifts the host's other values. Pending proxy migration now runs before + the first host write, including when edits are saved in separate calls. - A failed blob upload during `comfy build push` no longer writes the presigned PUT URL's query string to stdout, into the JSON envelope, or into a CI log. Both a rejected upload and a dropped connection quote the URL they were talking diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 33d4369f..ad6b02ef 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -3630,7 +3630,9 @@ def _apply_one_slot_impl(workflow: dict, addr: str, value: Any, graph: Graph) -> if err: raise ValueError(err) warnings = [dict(w, field=addr) for w in port.validate_catalog(value)] - if target.repair is not None: + if target.repair is not None or any( + e.plan != _promoted.PLAN_PREVIEW for e in _promoted.plan_proxy_migration(workflow, instance, graph) + ): # A legacy ``proxyWidgets`` promotion: run the frontend's forward # migration on this instance first (forking a shared definition, # since the repair mutates it), exactly as the op path does. diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 9afa0a26..8f400cc3 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -675,6 +675,13 @@ def _set_widget_impl( pi = _promoted.find_promoted(sg, defs, widget) promoted_meta["value_index"] = pi.value_index old = _promoted.effective_value(workflow, instance, widget, graph) + # Materializing declared-order values while legacy tuples remain + # makes a later migration reinterpret them in proxy order. + plan = _promoted.plan_proxy_migration(workflow, instance, graph, defs) + if any(e.plan != _promoted.PLAN_PREVIEW for e in plan): + promoted_meta["repair"] = { + "ids": _promoted.plan_repair_ids(list(target.segments), plan), + } inner_type, port = _engine.promoted_source_port(sg, pi, defs, graph) value, norm_note = _normalize_combo(graph, inner_type, port.name if port else widget, value) warnings: list[dict] = [] diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index 69edcf4a..7e72f511 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -428,6 +428,10 @@ Current contract, pinned: widget)` — deterministic, never random — so replay on any replica produces a byte-identical document. The repair mutates the definition, so a shared one is forked first exactly like an interior write. + A write to an already-linked input also carries `promoted.repair = {ids}` + when the instance has pending legacy value entries: those must be consumed + before materializing a declaration-order host array. `entry` is omitted + because the written input already exists; replay still runs the same flush. * OPEN: the shared-definition forking semantics above are apply-time behavior that rewrites `instance.type` without an explicit op saying so. A full specification (fork visibility, interaction with concurrent interior writes @@ -828,3 +832,5 @@ the op additionally carries `promoted.repair = {entry, ids}` with the subgraph-input and boundary-link ids the repair mints, derived by SHA-256 from `(instance path, source node, widget)` so replay anywhere is byte-identical. The pinned contract text in §8.7 states the full rule. +This flush also precedes writes to already-linked inputs while legacy value +entries remain; those ops carry `{ids}` without a newly repaired `entry`. diff --git a/tests/comfy_cli/command/test_workflow_edit_legacy_proxy.py b/tests/comfy_cli/command/test_workflow_edit_legacy_proxy.py index beab7ef4..62d37234 100644 --- a/tests/comfy_cli/command/test_workflow_edit_legacy_proxy.py +++ b/tests/comfy_cli/command/test_workflow_edit_legacy_proxy.py @@ -20,6 +20,7 @@ import copy import json +from itertools import permutations from pathlib import Path from typing import Any @@ -98,6 +99,46 @@ def test_legacy_write_repairs_the_definition_and_writes_the_host(graph): assert repair["ids"]["52.seed"]["links"][0] == sg["inputs"][-1]["linkIds"][0] +@pytest.mark.parametrize("order", list(permutations(["text", "width", "seed"]))) +@pytest.mark.parametrize("reload_between_writes", [False, True]) +def test_mixed_legacy_and_declared_writes_preserve_values(graph, order, reload_between_writes): + wf = _load(FLUX) + # Legacy widget order need not match the definition's linked-input order. + proxies = _node(wf, 56)["properties"]["proxyWidgets"] + proxies[0], proxies[1] = proxies[1], proxies[0] + baseline = copy.deepcopy(wf) + updates = {"text": "a lighthouse at dusk", "width": 768, "seed": 424242} + for name in ["seed", "text", "width"]: + baseline, _ = workflow_ops.set_widget(baseline, graph, 56, name, updates[name]) + + original = copy.deepcopy(wf) + ops = [] + for name in order: + wf, op = workflow_ops.set_widget(wf, graph, 56, name, updates[name]) + ops.append(op) + if reload_between_writes: + wf = json.loads(json.dumps(wf)) + + assert _node(wf, 56)["widgets_values"] == _node(baseline, 56)["widgets_values"] + slots = {s["address"]: s for s in graph.get_template_schema("t", wf)["slots"]} + for name, value in updates.items(): + assert promoted.effective_value(wf, _node(wf, 56), name, graph) == value + assert slots[f"56.{name}"]["current_value"] == value + assert _api(wf) == _api(baseline) + + via_slots = copy.deepcopy(original) + if reload_between_writes: + for name in order: + via_slots, _ = graph.apply_slots(via_slots, {f"56.{name}": updates[name]}) + via_slots = json.loads(json.dumps(via_slots)) + else: + via_slots, _ = graph.apply_slots(via_slots, {f"56.{name}": updates[name] for name in order}) + assert _stripped(via_slots) == _stripped(wf) + for op in ops: + workflow_ops.apply_op(original, op, graph) + assert _stripped(original) == _stripped(wf) + + def test_interior_address_of_a_legacy_promotion_is_redirected_to_the_host(graph): wf = _load(FLUX) wf, op = workflow_ops.set_widget(wf, graph, "56/52", "seed", 5) @@ -227,14 +268,15 @@ def test_concurrent_repairs_of_one_instance_converge(graph): assert _node(ab, 143)["widgets_values"] == ["Nano Banana 2", "1K", "1:1"] -def test_shared_definition_is_forked_not_mutated(graph): +@pytest.mark.parametrize(("widget", "value"), [("seed", 5), ("text", "new prompt")]) +def test_shared_definition_is_forked_not_mutated(graph, widget, value): wf = _load(FLUX) sibling = copy.deepcopy(_node(wf, 56)) sibling["id"] = 156 wf["nodes"].append(sibling) original_def_id = sibling["type"] original_def = copy.deepcopy(_def_of(wf, sibling)) - wf, op = workflow_ops.set_widget(wf, graph, 56, "seed", 5) + wf, _ = workflow_ops.set_widget(wf, graph, 56, widget, value) assert _node(wf, 56)["type"] == _deterministic_fork_id(original_def_id, 56) assert _node(wf, 156)["type"] == original_def_id assert _def_of(wf, _node(wf, 156)) == original_def @@ -247,7 +289,8 @@ def test_shared_definition_is_forked_not_mutated(graph): assert _node(wf, 156)["type"] == original_def_id assert [i["name"] for i in _def_of(wf, _node(wf, 156))["inputs"]][-1] == "seed" assert _def_of(wf, _node(wf, 56)) == forked - assert _node(wf, 56)["widgets_values"][-1] == 5 and _node(wf, 156)["widgets_values"][-1] == 6 + assert promoted.effective_value(wf, _node(wf, 56), widget, graph) == value + assert _node(wf, 156)["widgets_values"][-1] == 6 # --------------------------------------------------------------------------- #