fix: make Langflow-synced flows visible (lfx volume perms, pull dedup)

This commit is contained in:
Jonas Miederer 2026-09-25 08:19:42 +02:00
parent 1326cece42
commit 95a1c1afb7
12 changed files with 5460 additions and 3859 deletions

View file

@ -49,7 +49,12 @@ async def pull_all_from_langflow(
try:
exported = await langflow.export_flow(flow_id)
doc = normalize_exported_flow(exported, flow_id)
path = export.write_flow_file(flows_dir, doc, name=doc["name"])
reuse, identical = export.find_reuse_path(flows_dir, doc)
if identical and reuse is not None:
pulled.append(reuse.stem)
continue
target = reuse or export.flow_path(flows_dir, export.slugify(doc["name"]))
path = export.write_flow_bytes(target, doc)
pulled.append(path.stem)
except (LangflowError, OSError, ValueError) as exc:
errors[flow_id] = str(exc)

View file

@ -36,17 +36,45 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
return Path(flows_dir) / f"{stem}.json"
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
"""Write a flow document to backend/flows atomically (tmp + rename).
def _read_doc(path: Path) -> dict | None:
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return doc if isinstance(doc, dict) else None
The filename derives from `name` (or the document's own name). Returns
the written path.
def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool]:
"""Existing file to reuse for `doc`: (path, identical).
Matches identical content first (any filename), then the same flow name.
Returns (None, False) when no candidate exists (caller writes a slug file).
This keeps pulls idempotent: a flow seeded from `simple_rule.json` is
updated in place instead of duplicated as `simple-rule.json`.
"""
flows_dir = Path(flows_dir)
flows_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(name or doc.get("name") or "flow")
target = flow_path(flows_dir, stem)
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
if not flows_dir.is_dir():
return None, False
wanted_hash = content_hash(doc)
wanted_name = doc.get("name")
named: Path | None = None
for path in iter_flow_files(flows_dir):
existing = _read_doc(path)
if existing is None:
continue
if content_hash(existing) == wanted_hash:
return path, True
if named is None and wanted_name and existing.get("name") == wanted_name:
named = path
return (named, False) if named is not None else (None, False)
def write_flow_bytes(target: Path, doc: dict) -> Path:
"""Write a flow document to an explicit path atomically (tmp + rename)."""
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
)
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc))
@ -60,6 +88,18 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
return target
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
"""Write a flow document to backend/flows atomically (tmp + rename).
The filename derives from `name` (or the document's own name). Returns
the written path.
"""
flows_dir = Path(flows_dir)
flows_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(name or doc.get("name") or "flow")
return write_flow_bytes(flow_path(flows_dir, stem), doc)
def iter_flow_files(flows_dir: str | Path) -> list[Path]:
"""Sorted list of candidate flow files (top level only)."""
return sorted(Path(flows_dir).glob(FLOWS_GLOB))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

747
backend/flows/new-flow.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -37,3 +37,30 @@ def test_iter_flow_files_only_top_level_json(tmp_path):
sub.mkdir()
(sub / "c.json").write_text("{}")
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
def test_find_reuse_path_matches_identical_content_first(tmp_path):
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
other = tmp_path / "other-name.json"
other.write_text(export.canonical_bytes(doc).decode())
path, identical = export.find_reuse_path(tmp_path, dict(doc))
assert (path, identical) == (other, True)
def test_find_reuse_path_matches_same_name(tmp_path):
old = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}, "extra": 1}
(tmp_path / "simple_rule.json").write_text(json.dumps(old))
new = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
path, identical = export.find_reuse_path(tmp_path, new)
assert (path, identical) == (tmp_path / "simple_rule.json", False)
def test_find_reuse_path_no_match_and_missing_dir(tmp_path):
(tmp_path / "a.json").write_text(json.dumps({"name": "Other"}))
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
assert export.find_reuse_path(tmp_path / "gibts-nicht", {"name": "Neu"}) == (None, False)
def test_find_reuse_path_skips_unreadable_files(tmp_path):
(tmp_path / "kaputt.json").write_text("{kein json")
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)

View file

@ -176,6 +176,31 @@ async def test_sync_endpoint_pulls_from_langflow_then_syncs(
assert body["skipped"] == ["gehaltserh-hung"]
async def test_sync_endpoint_pull_reuses_same_named_file(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
"""A pulled flow seeded from simple_rule.json updates it in place
instead of duplicating it as simple-rule.json (slug drift)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path) # fixture shape, name "simple_rule"
langflow_id = "44444444-5555-4666-8777-888888888888"
override_langflow(
flows=[{"id": langflow_id, "name": "simple_rule"}],
docs={langflow_id: _langflow_doc("simple_rule", langflow_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["simple_rule"]
assert body["added"] == ["simple_rule"]
assert body["errors"] == {}
assert not (tmp_path / "simple-rule.json").exists()
response = await client.post("/api/flows/sync")
assert response.json()["skipped"] == ["simple_rule"]
async def test_sync_endpoint_pull_error_continues(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):