feat(graph): make the visual editor a full wiring control surface

Lets users wire the system end-to-end from the graph, and fixes the core
bug that made drag-to-wire silently fail.

- Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes
  the target's discriminator (source_type/stream_type/target_type) into the
  partial PUT, so value/colour-strip/audio/picture sources and output targets
  all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py.
- Re-wire composite layers / mapped zones from the graph (right-click a
  layer/zone source edge -> Re-wire). Whole-list write preserves every sibling
  layer/zone setting, with an optimistic-concurrency guard and undo.
- Secret-safe /graph topology: project entities to id/name/subtype + reference
  roots so the endpoint cannot leak webhook tokens or other credentials.
- Carry slot indices on list edges; node custom-icon + schema-drift refinements;
  rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
This commit is contained in:
2026-05-29 02:29:19 +03:00
parent 05cf121666
commit 2e51f46dfd
13 changed files with 677 additions and 53 deletions
+80 -1
View File
@@ -4,6 +4,10 @@ These exercise reference extraction, topology building, dependents, cycle and
dangling-reference detection without booting the app or any store.
"""
import json
import re
from dataclasses import dataclass, field
from ledgrab.api.graph_schema import (
CONNECTION_SCHEMA,
ENTITY_KINDS,
@@ -11,7 +15,10 @@ from ledgrab.api.graph_schema import (
detect_cycles,
extract_refs,
find_dependents,
graph_field_roots,
is_editable,
schema_for_kind,
serialize_entity_for_graph,
validate_connection,
would_create_cycle,
)
@@ -249,7 +256,79 @@ def test_validate_connection_rejects_list_field():
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
)
assert ok is False
assert "List connection" in err
assert "not editable" in err
def test_validate_connection_rejects_color_bindable():
# Colour bindings are structurally bindable but not graph-editable (a value
# source can't drive a colour).
entities = {
"color_strip_source": [
{"id": "css_1", "name": "X", "source_type": "single_color", "color": [255, 0, 0]}
],
"value_source": [{"id": "vs_1", "name": "V", "source_type": "static"}],
}
ok, err = validate_connection(
entities, "color_strip_source", "css_1", "color.source_id", "vs_1"
)
assert ok is False
assert "not editable" in err
@dataclass
class _FakeRule:
token: str = "SUPER_SECRET_WEBHOOK_TOKEN"
@dataclass
class _FakeAutomation:
id: str = "auto_1"
name: str = "A"
enabled: bool = True
scene_preset_id: str = "sp_1"
rules: list = field(default_factory=lambda: [_FakeRule()])
# Keys that must NEVER appear in the /graph projection allowlist.
_SECRET_KEY_RE = re.compile(
r"token|password|secret|credential|api[_-]?key|_key$|username|\burl\b|\bhost\b", re.I
)
def test_projection_roots_never_expose_secrets():
# The /graph projection (graph_field_roots) is the leak boundary. Assert no
# kind's allowlist contains a secret-bearing key — locks the boundary against
# future schema drift (a new reference field whose root looks like a secret).
for kind in ENTITY_KINDS:
for root in graph_field_roots(kind):
assert not _SECRET_KEY_RE.search(
root
), f"{kind}: projected root {root!r} looks secret-bearing"
def test_serialize_entity_for_graph_drops_secrets():
# The graph projection must strip everything except id/name/type + reference
# roots — a deep asdict would otherwise leak the webhook token (a real,
# auth-equivalent secret) in the /graph response.
projected = serialize_entity_for_graph("automation", _FakeAutomation())
assert projected["id"] == "auto_1"
assert projected["name"] == "A"
assert projected["scene_preset_id"] == "sp_1" # reference root kept
assert "rules" not in projected # non-reference field dropped
assert "enabled" not in projected
assert "SUPER_SECRET_WEBHOOK_TOKEN" not in json.dumps(projected)
def test_is_editable_classifies_fields():
by_field = {(c.target_kind, c.field): c for c in CONNECTION_SCHEMA}
# Top-level reference and single-level BindableFloat → editable.
assert is_editable(by_field[("output_target", "device_id")])
assert is_editable(by_field[("color_strip_source", "brightness.source_id")])
assert is_editable(by_field[("output_target", "transition.source_id")])
# Colour binding, list slot, and double-nested → NOT editable.
assert not is_editable(by_field[("color_strip_source", "color.source_id")])
assert not is_editable(by_field[("color_strip_source", "layers[].source_id")])
assert not is_editable(by_field[("output_target", "settings.brightness.source_id")])
def test_validate_connection_rejects_cycle():