Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions comfy_cli/workflow_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
6 changes: 6 additions & 0 deletions docs/op-vocabulary-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
49 changes: 46 additions & 3 deletions tests/comfy_cli/command/test_workflow_edit_legacy_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import copy
import json
from itertools import permutations
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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


# --------------------------------------------------------------------------- #
Expand Down
Loading