"""Canonical flow files: backend/flows/.json is the source of truth. One Langflow export per file, committed to git. This module only reads and writes the directory; reconciliation into the DB lives in sync.py. """ from __future__ import annotations import hashlib import json import os import re from pathlib import Path FLOWS_GLOB = "*.json" #: Opt-out list in the flows dir (one stem per line, `#` comments): #: listed flows stay in Langflow only — never synced, deployed, #: validated, or shown in the frontend. IGNORE_FILENAME = ".flowsignore" # mtime-keyed cache: the watcher re-reads per event, the file rarely changes. _ignore_cache: dict[str, tuple[float, set[str]]] = {} def ignored_stems(flows_dir: str | Path) -> set[str]: """Stems excluded from the pipeline (empty when no ignore file).""" key = str(Path(flows_dir)) try: mtime = (Path(flows_dir) / IGNORE_FILENAME).stat().st_mtime except OSError: _ignore_cache.pop(key, None) return set() cached = _ignore_cache.get(key) if cached is not None and cached[0] == mtime: return cached[1] try: text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8") except OSError: return set() stems = { line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#") } _ignore_cache[key] = (mtime, stems) return stems def filename_stem(name: str) -> str: """Map a flow name to a filename stem, keeping the original name. Only filesystem-reserved characters (``/ \\ : * ? " < > |`` and control chars) become ``-``; leading/trailing whitespace and trailing dots are stripped (Windows). Case, spaces, underscores and umlauts are preserved: ``test_flow`` stays ``test_flow``. """ stem = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "-", name).strip() stem = stem.rstrip(".") return stem or "flow" def canonical_bytes(doc: dict) -> bytes: """Deterministic serialization: stable bytes for stable git diffs.""" return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8") def content_hash(doc: dict) -> str: """Hash of the parsed document (formatting-independent).""" return hashlib.sha256(canonical_bytes(doc)).hexdigest() def flow_path(flows_dir: str | Path, stem: str) -> Path: return Path(flows_dir) / f"{stem}.json" 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 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) 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). The file ends up world-readable (0o644): atomicwrites follows the umask, which would lock out the other containers (Langflow/LFX run as different users and crash on unreadable seed files). """ from atomicwrites import atomic_write target.parent.mkdir(parents=True, exist_ok=True) with atomic_write(target, mode="wb", overwrite=True) as tmp: tmp.write(canonical_bytes(doc)) os.chmod(target, 0o644) 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 = filename_stem(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, *, ignored: set[str] | None = None) -> list[Path]: """Sorted list of candidate flow files (top level only, minus ignored).""" skip = ignored if ignored is not None else ignored_stems(flows_dir) return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip)