feat: canonical flow directory with sync engine (files are source of truth)
This commit is contained in:
parent
7d5ade6b6a
commit
c65e3ed332
16 changed files with 4167 additions and 42 deletions
|
|
@ -6,6 +6,10 @@ Langflow UI would export. Run with the backend venv (has the `lfx` dev dep)::
|
|||
|
||||
uv run python scripts/make_flow_fixtures.py
|
||||
|
||||
Writes to two places:
|
||||
- backend/tests/fixtures/flows/ (test fixtures, never deployed as-is)
|
||||
- backend/flows/ (canonical source of truth, synced into DB + LFX)
|
||||
|
||||
Then verify::
|
||||
|
||||
uv run lfx validate --level 4 backend/tests/fixtures/flows/
|
||||
|
|
@ -21,6 +25,7 @@ from pathlib import Path
|
|||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
FIXTURE_DIR = REPO_ROOT / "backend" / "tests" / "fixtures" / "flows"
|
||||
FLOW_DIR = REPO_ROOT / "backend" / "flows"
|
||||
|
||||
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
|
||||
|
||||
|
|
@ -430,14 +435,16 @@ def build_approval_handoff() -> dict:
|
|||
|
||||
def main() -> None:
|
||||
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
flows = {
|
||||
"simple_rule.json": build_simple_rule(),
|
||||
"approval_handoff.json": build_approval_handoff(),
|
||||
}
|
||||
for filename, doc in flows.items():
|
||||
path = FIXTURE_DIR / filename
|
||||
path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
||||
print(f"wrote {path}")
|
||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
|
||||
target.write_text(payload)
|
||||
print(f"wrote {target}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
89
backend/scripts/sync_flows.py
Normal file
89
backend/scripts/sync_flows.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Reconcile backend/flows/*.json into the DB + LFX.
|
||||
|
||||
Usage (from backend/, venv active):
|
||||
uv run python scripts/sync_flows.py --sync [--dir backend/flows]
|
||||
uv run python scripts/sync_flows.py --pull [--sync] # export all Langflow flows to files first
|
||||
|
||||
Exit codes: 0 ok, 1 sync errors, 2 flows dir missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.db import get_engine, reset_engine
|
||||
from app.flows import export, sync
|
||||
from app.flows.langflow_client import LangflowClient
|
||||
from app.flows.lfx_client import LfxClient
|
||||
from app.flows.schemas import SyncReport
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
async def pull_from_langflow(flows_dir: Path) -> dict:
|
||||
"""Export every Langflow flow to its own canonical file. Returns a report."""
|
||||
client = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key)
|
||||
headers = await client.list_flows()
|
||||
pulled: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
for header in headers:
|
||||
flow_id = str(header.get("id", ""))
|
||||
try:
|
||||
doc = await client.export_flow(flow_id)
|
||||
name = doc.get("name") or flow_id
|
||||
path = export.write_flow_file(flows_dir, doc, name=name)
|
||||
pulled.append(path.stem)
|
||||
except Exception as exc: # noqa: BLE001 - pull-all is total
|
||||
errors[flow_id] = str(exc)
|
||||
return {"pulled": sorted(pulled), "errors": errors}
|
||||
|
||||
|
||||
async def run_sync(flows_dir: Path) -> SyncReport:
|
||||
reset_engine()
|
||||
session = Session(get_engine())
|
||||
try:
|
||||
lfx = LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
return await sync.sync_from_directory(session, lfx, flows_dir)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
async def main_async(args) -> int:
|
||||
flows_dir = Path(args.dir or settings.flows_dir)
|
||||
if args.pull:
|
||||
pulled = await pull_from_langflow(flows_dir)
|
||||
print(json.dumps(pulled, indent=2, ensure_ascii=False))
|
||||
if pulled["errors"]:
|
||||
return 1
|
||||
if args.sync or not args.pull:
|
||||
try:
|
||||
report = await run_sync(flows_dir)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(report.model_dump_json(indent=2))
|
||||
return 1 if report.errors else 0
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pull", action="store_true",
|
||||
help="export all Langflow flows to backend/flows first")
|
||||
parser.add_argument("--sync", action="store_true",
|
||||
help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None,
|
||||
help="flows directory (default: settings.flows_dir)")
|
||||
args = parser.parse_args()
|
||||
if not args.pull and not args.sync:
|
||||
args.sync = True
|
||||
return asyncio.run(main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue