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
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
|
@ -16,6 +17,7 @@ from app.settings import settings
|
|||
|
||||
from . import runner, store
|
||||
from .deploy import import_from_langflow
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
from .langflow_client import (
|
||||
LangflowClient,
|
||||
LangflowError,
|
||||
|
|
@ -44,6 +46,7 @@ from .schemas import (
|
|||
FlowVersionRead,
|
||||
RunRead,
|
||||
RunRequest,
|
||||
SyncReport,
|
||||
)
|
||||
|
||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||
|
|
@ -70,6 +73,7 @@ def _read_definition(d) -> FlowDefinitionRead:
|
|||
name=d.name,
|
||||
description=d.description,
|
||||
status=d.status,
|
||||
source_path=d.source_path,
|
||||
continuation_flow_id=d.continuation_flow_id,
|
||||
created_at=d.created_at,
|
||||
)
|
||||
|
|
@ -134,6 +138,18 @@ def list_flows(session: Session = Depends(get_db)):
|
|||
return [_read_definition(d) for d in store.list_definitions(session)]
|
||||
|
||||
|
||||
@flows_router.post("/sync", response_model=SyncReport)
|
||||
async def sync_flows(
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
):
|
||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
||||
try:
|
||||
return await sync_from_directory(session, lfx, Path(settings.flows_dir))
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
try:
|
||||
|
|
@ -178,8 +194,12 @@ async def deploy_version(
|
|||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
try:
|
||||
# Deterministic LFX id: same (definition, version) always maps to the
|
||||
# same registration, no matter which path deployed it.
|
||||
uploaded = await lfx.upload_flow(
|
||||
version.langflow_json, flow_id=str(version.id), replace=True
|
||||
version.langflow_json,
|
||||
flow_id=lfx_id_for(flow_id, version_no),
|
||||
replace=True,
|
||||
)
|
||||
except LfxInvalidFlow as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""Authoring pipeline: Langflow export -> versioned definition -> LFX deploy."""
|
||||
"""Authoring pipeline: Langflow export -> canonical file -> version -> LFX deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import FlowDefinition, FlowVersion
|
||||
from app.settings import settings
|
||||
|
||||
from . import store
|
||||
from . import export, store, sync
|
||||
from .langflow_client import LangflowClient
|
||||
from .lfx_client import LfxClient
|
||||
from .schemas import FlowDefinitionCreate, FlowVersionCreate
|
||||
|
||||
|
||||
async def import_from_langflow(
|
||||
|
|
@ -22,39 +22,31 @@ async def import_from_langflow(
|
|||
author: str = "",
|
||||
deploy: bool = True,
|
||||
) -> tuple[FlowDefinition, FlowVersion]:
|
||||
"""Export a flow from Langflow, store it as a new version, deploy to LFX.
|
||||
"""Export a flow from Langflow, persist it to backend/flows, then sync.
|
||||
|
||||
Definitions are matched by name (explicit `name` wins, else the exported
|
||||
name); every import creates a new immutable version.
|
||||
The canonical file is written first so the directory stays the source of
|
||||
truth; sync_document() derives the definition + version from it.
|
||||
"""
|
||||
export = await langflow.export_flow(langflow_flow_id)
|
||||
flow_name = name or export.get("name") or f"langflow-{langflow_flow_id}"
|
||||
exported = await langflow.export_flow(langflow_flow_id)
|
||||
flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}"
|
||||
langflow_json = {
|
||||
"id": export.get("id", langflow_flow_id),
|
||||
"id": exported.get("id", langflow_flow_id),
|
||||
"name": flow_name,
|
||||
"description": export.get("description") or "",
|
||||
"endpoint_name": export.get("endpoint_name"),
|
||||
"data": export.get("data", {}),
|
||||
"tags": export.get("tags", []),
|
||||
"webhook": export.get("webhook", False),
|
||||
"is_component": export.get("is_component", False),
|
||||
"description": exported.get("description") or "",
|
||||
"endpoint_name": exported.get("endpoint_name"),
|
||||
"data": exported.get("data", {}),
|
||||
"tags": exported.get("tags", []),
|
||||
"webhook": exported.get("webhook", False),
|
||||
"is_component": exported.get("is_component", False),
|
||||
}
|
||||
try:
|
||||
definition = next(
|
||||
d for d in store.list_definitions(session) if d.name == flow_name
|
||||
)
|
||||
except StopIteration:
|
||||
definition = store.create_definition(
|
||||
session, FlowDefinitionCreate(name=flow_name)
|
||||
)
|
||||
version = store.create_version(
|
||||
path = export.write_flow_file(settings.flows_dir, langflow_json, name=flow_name)
|
||||
definition, version, _action = await sync.sync_document(
|
||||
session,
|
||||
definition.id,
|
||||
FlowVersionCreate(langflow_json=langflow_json, author=author),
|
||||
lfx,
|
||||
langflow_json,
|
||||
source_filename=path.name,
|
||||
author=author,
|
||||
deploy=deploy,
|
||||
)
|
||||
if deploy:
|
||||
uploaded = await lfx.upload_flow(
|
||||
version.langflow_json, flow_id=str(version.id), replace=True
|
||||
)
|
||||
version = store.mark_deployed(session, version, uploaded.id)
|
||||
assert version is not None # sync_document always versions on import
|
||||
return definition, version
|
||||
|
|
|
|||
71
backend/app/flows/export.py
Normal file
71
backend/app/flows/export.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Canonical flow files: backend/flows/<name>.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
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
FLOWS_GLOB = "*.json"
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Map a flow name to a safe filename stem (lowercase, [a-z0-9-])."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return slug 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 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")
|
||||
target = flow_path(flows_dir, stem)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
os.replace(tmp_name, target)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
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))
|
||||
|
|
@ -17,6 +17,8 @@ class FlowDefinitionRead(BaseModel):
|
|||
name: str
|
||||
description: str
|
||||
status: str
|
||||
# backend/flows filename this definition syncs from (None if API-created).
|
||||
source_path: str | None = None
|
||||
continuation_flow_id: UUID | None
|
||||
created_at: datetime
|
||||
|
||||
|
|
@ -110,3 +112,16 @@ class DecisionRequest(BaseModel):
|
|||
class DecisionRead(BaseModel):
|
||||
task: ApprovalRead
|
||||
continuation_run: RunRead | None
|
||||
|
||||
|
||||
class SyncReport(BaseModel):
|
||||
"""Result of reconciling backend/flows/*.json into DB + LFX.
|
||||
|
||||
Lists hold filename stems; errors maps filename to reason.
|
||||
"""
|
||||
|
||||
added: list[str] = Field(default_factory=list)
|
||||
updated: list[str] = Field(default_factory=list)
|
||||
deleted: list[str] = Field(default_factory=list)
|
||||
skipped: list[str] = Field(default_factory=list)
|
||||
errors: dict[str, str] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,82 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
|
|||
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
|
||||
|
||||
|
||||
def _validate_flow_json(flow_json: dict) -> None:
|
||||
def get_definition_by_source_path(
|
||||
session: Session, source_path: str
|
||||
) -> FlowDefinition | None:
|
||||
"""Find the definition synced from a given backend/flows filename."""
|
||||
return session.exec(
|
||||
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
|
||||
).first()
|
||||
|
||||
|
||||
def set_definition_name(
|
||||
session: Session, definition: FlowDefinition, name: str
|
||||
) -> FlowDefinition:
|
||||
"""Rename a definition and record its source file. Name clashes 409."""
|
||||
definition.name = name
|
||||
session.add(definition)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise ConflictError(f"flow {name!r} already exists") from exc
|
||||
session.refresh(definition)
|
||||
return definition
|
||||
|
||||
|
||||
def next_version_no(session: Session, flow_id: UUID) -> int:
|
||||
max_no = session.exec(
|
||||
select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id)
|
||||
).one()
|
||||
return (max_no or 0) + 1
|
||||
|
||||
|
||||
def delete_definition_cascade(
|
||||
session: Session, definition: FlowDefinition
|
||||
) -> dict[str, int]:
|
||||
"""Remove a definition and its whole subtree (tasks, runs, versions).
|
||||
|
||||
Used when the canonical file disappears: the directory is the source of
|
||||
truth, so the derived registry follows. Commits once.
|
||||
"""
|
||||
version_ids = list(
|
||||
session.exec(
|
||||
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
|
||||
).all()
|
||||
)
|
||||
task_count = 0
|
||||
run_count = 0
|
||||
if version_ids:
|
||||
tasks = list(
|
||||
session.exec(
|
||||
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
|
||||
).all()
|
||||
)
|
||||
runs = list(
|
||||
session.exec(
|
||||
select(FlowRun).where(FlowRun.version_id.in_(version_ids))
|
||||
).all()
|
||||
)
|
||||
task_count, run_count = len(tasks), len(runs)
|
||||
for row in (*tasks, *runs):
|
||||
session.delete(row)
|
||||
for version in session.exec(
|
||||
select(FlowVersion).where(FlowVersion.flow_id == definition.id)
|
||||
).all():
|
||||
session.delete(version)
|
||||
session.delete(definition)
|
||||
session.commit()
|
||||
return {
|
||||
"definitions": 1,
|
||||
"versions": len(version_ids),
|
||||
"runs": run_count,
|
||||
"tasks": task_count,
|
||||
}
|
||||
|
||||
|
||||
def validate_flow_json(flow_json: dict) -> None:
|
||||
"""Reject anything that is not a Langflow-export-shaped document."""
|
||||
if not isinstance(flow_json, dict):
|
||||
raise InvalidError("langflow_json must be an object")
|
||||
data = flow_json.get("data")
|
||||
|
|
@ -70,7 +145,7 @@ def create_version(
|
|||
session: Session, flow_id: UUID, data: FlowVersionCreate
|
||||
) -> FlowVersion:
|
||||
get_definition(session, flow_id)
|
||||
_validate_flow_json(data.langflow_json)
|
||||
validate_flow_json(data.langflow_json)
|
||||
max_no = session.exec(
|
||||
select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id)
|
||||
).one()
|
||||
|
|
|
|||
274
backend/app/flows/sync.py
Normal file
274
backend/app/flows/sync.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Reconciliation: backend/flows/*.json (source of truth) -> DB + LFX.
|
||||
|
||||
The ONLY place that derives registry state from the canonical directory.
|
||||
Triggers (CLI, POST /api/flows/sync, embedded watcher) call
|
||||
sync_from_directory(); single-document callers (e.g. /import) use
|
||||
sync_document(). Everything is async (the LFX client is async) and guarded
|
||||
by a module lock so concurrent triggers serialize on SQLite's single writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session
|
||||
from watchfiles import DefaultFilter, awatch
|
||||
|
||||
from app.db import get_engine
|
||||
from app.models import FlowDefinition, FlowVersion
|
||||
from app.settings import settings
|
||||
|
||||
from . import export, store
|
||||
from .lfx_client import LfxClient, LfxError
|
||||
from .schemas import FlowDefinitionCreate, FlowVersionCreate, SyncReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Serializes all writers in this process (sync engine + manual API writes).
|
||||
sync_lock = threading.Lock()
|
||||
|
||||
|
||||
def lfx_id_for(definition_id: UUID, version_no: int) -> str:
|
||||
"""Deterministic LFX id (a real UUID: LFX validates the format).
|
||||
|
||||
Same (definition, version) always maps to the same id, so re-syncs and
|
||||
crash recovery overwrite instead of orphaning LFX registrations.
|
||||
"""
|
||||
return str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
|
||||
)
|
||||
|
||||
|
||||
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
|
||||
versions = store.list_versions(session, flow_id)
|
||||
return versions[-1] if versions else None
|
||||
|
||||
|
||||
async def sync_document(
|
||||
session: Session,
|
||||
lfx: LfxClient,
|
||||
doc: dict,
|
||||
*,
|
||||
source_filename: str,
|
||||
author: str = "",
|
||||
deploy: bool = True,
|
||||
) -> tuple[FlowDefinition, FlowVersion | None, str]:
|
||||
"""Reconcile one parsed document. Returns (definition, version, action).
|
||||
|
||||
Actions: "added" (new definition + version), "updated" (new version),
|
||||
"skipped" (content unchanged). Raises store.ConflictError on name
|
||||
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
|
||||
With deploy=False a version is recorded without touching LFX.
|
||||
"""
|
||||
store.validate_flow_json(doc)
|
||||
name = doc.get("name") or Path(source_filename).stem
|
||||
with sync_lock:
|
||||
definition = store.get_definition_by_source_path(session, source_filename)
|
||||
if definition is None:
|
||||
definition = store.create_definition(
|
||||
session, FlowDefinitionCreate(name=name)
|
||||
)
|
||||
definition.source_path = source_filename
|
||||
session.add(definition)
|
||||
session.commit()
|
||||
session.refresh(definition)
|
||||
action = "added"
|
||||
else:
|
||||
action = "updated"
|
||||
if definition.name != name:
|
||||
store.set_definition_name(session, definition, name)
|
||||
|
||||
latest = _latest_version(session, definition.id)
|
||||
if (
|
||||
latest is not None
|
||||
and export.content_hash(latest.langflow_json)
|
||||
== export.content_hash(doc)
|
||||
):
|
||||
return definition, latest, "skipped"
|
||||
|
||||
if not deploy:
|
||||
version = store.create_version(
|
||||
session,
|
||||
definition.id,
|
||||
FlowVersionCreate(langflow_json=doc, author=author),
|
||||
)
|
||||
return definition, version, action
|
||||
|
||||
version_no = store.next_version_no(session, definition.id)
|
||||
lfx_id = lfx_id_for(definition.id, version_no)
|
||||
uploaded = await lfx.upload_flow(doc, flow_id=lfx_id, replace=True)
|
||||
version = store.create_version(
|
||||
session,
|
||||
definition.id,
|
||||
FlowVersionCreate(langflow_json=doc, author=author),
|
||||
)
|
||||
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
||||
raise store.ConflictError(
|
||||
f"concurrent version write for flow {definition.id}"
|
||||
)
|
||||
version = store.mark_deployed(session, version, uploaded.id)
|
||||
if (
|
||||
latest is not None
|
||||
and latest.lfx_flow_id
|
||||
and latest.lfx_flow_id != uploaded.id
|
||||
):
|
||||
try:
|
||||
await lfx.delete_flow(latest.lfx_flow_id)
|
||||
except LfxError as exc:
|
||||
logger.warning(
|
||||
"LFX unregister %s failed (continuing): %s",
|
||||
latest.lfx_flow_id,
|
||||
exc,
|
||||
)
|
||||
return definition, version, action
|
||||
|
||||
|
||||
async def delete_definition_tree(
|
||||
session: Session, lfx: LfxClient, definition: FlowDefinition
|
||||
) -> dict[str, int]:
|
||||
"""Remove a definition whose canonical file is gone.
|
||||
|
||||
LFX registrations go first (best-effort, fail-closed for new runs), the
|
||||
DB subtree (tasks, runs, versions, definition) after. Deterministic LFX
|
||||
ids make any crash in between self-healing on the next sync.
|
||||
"""
|
||||
for version in store.list_versions(session, definition.id):
|
||||
if version.lfx_flow_id:
|
||||
try:
|
||||
await lfx.delete_flow(version.lfx_flow_id)
|
||||
except LfxError as exc:
|
||||
logger.warning(
|
||||
"LFX unregister %s failed (continuing): %s",
|
||||
version.lfx_flow_id,
|
||||
exc,
|
||||
)
|
||||
with sync_lock:
|
||||
return store.delete_definition_cascade(session, definition)
|
||||
|
||||
|
||||
async def sync_from_directory(
|
||||
session: Session, lfx: LfxClient, flows_dir: str | Path
|
||||
) -> SyncReport:
|
||||
"""Reconcile the whole canonical directory. Per-file problems land in
|
||||
report.errors (never raised); a missing directory raises FileNotFoundError
|
||||
so misconfiguration fails loudly instead of wiping the registry.
|
||||
"""
|
||||
flows_dir = Path(flows_dir)
|
||||
report = SyncReport()
|
||||
if not flows_dir.is_dir():
|
||||
raise FileNotFoundError(f"flows directory missing: {flows_dir}")
|
||||
files = {path.name: path for path in export.iter_flow_files(flows_dir)}
|
||||
for filename, path in files.items():
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
report.errors[filename] = f"unreadable: {exc}"
|
||||
continue
|
||||
if not isinstance(doc, dict):
|
||||
report.errors[filename] = "top-level JSON must be an object"
|
||||
continue
|
||||
try:
|
||||
_, _, action = await sync_document(
|
||||
session, lfx, doc, source_filename=filename
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
report.errors[filename] = str(exc)
|
||||
continue
|
||||
getattr(report, action).append(path.stem)
|
||||
known = {
|
||||
definition.source_path
|
||||
for definition in store.list_definitions(session)
|
||||
if definition.source_path
|
||||
}
|
||||
for stale in sorted(known - set(files)):
|
||||
definition = store.get_definition_by_source_path(session, stale)
|
||||
if definition is None: # pragma: no cover - defensive
|
||||
continue
|
||||
try:
|
||||
await delete_definition_tree(session, lfx, definition)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
report.errors[stale] = str(exc)
|
||||
continue
|
||||
report.deleted.append(Path(stale).stem)
|
||||
return report
|
||||
|
||||
|
||||
def default_session_factory() -> Callable[[], Session]:
|
||||
return lambda: Session(get_engine())
|
||||
|
||||
|
||||
def default_lfx_factory() -> Callable[[], LfxClient]:
|
||||
return lambda: LfxClient(
|
||||
settings.lfx_url, api_key=settings.lfx_api_key or None
|
||||
)
|
||||
|
||||
|
||||
def run_sync_blocking(
|
||||
flows_dir: str | Path,
|
||||
*,
|
||||
session_factory: Callable[[], Session] | None = None,
|
||||
lfx_factory: Callable[[], LfxClient] | None = None,
|
||||
) -> SyncReport:
|
||||
"""Blocking one-shot sync for worker threads (lifespan, watcher)."""
|
||||
session = (session_factory or default_session_factory())()
|
||||
try:
|
||||
lfx = (lfx_factory or default_lfx_factory())()
|
||||
return asyncio.run(sync_from_directory(session, lfx, Path(flows_dir)))
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class _JsonFilter(DefaultFilter):
|
||||
def __call__(self, change, path: str) -> bool: # type: ignore[override]
|
||||
return super().__call__(change, path) and path.endswith(".json")
|
||||
|
||||
|
||||
async def watch_forever(
|
||||
stop: asyncio.Event,
|
||||
flows_dir: str | Path,
|
||||
run_sync: Callable[[], SyncReport] | None = None,
|
||||
) -> None:
|
||||
"""Run until `stop` is set: sync on every batch of .json changes.
|
||||
|
||||
All failures are logged, never raised — a broken file must not kill the
|
||||
watcher. `run_sync` is injectable for tests.
|
||||
"""
|
||||
flows_dir = Path(flows_dir)
|
||||
run = run_sync or (lambda: run_sync_blocking(flows_dir))
|
||||
while not stop.is_set() and not flows_dir.is_dir():
|
||||
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if stop.is_set():
|
||||
return
|
||||
logger.info("watching %s", flows_dir)
|
||||
async for _ in awatch(
|
||||
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
|
||||
):
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
report = await asyncio.to_thread(run)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("flow sync failed: %s", exc)
|
||||
continue
|
||||
except Exception: # noqa: BLE001 - watcher must survive
|
||||
logger.exception("flow sync failed")
|
||||
continue
|
||||
logger.info(
|
||||
"flow sync: +%d ~%d -%d skipped=%d errors=%d",
|
||||
len(report.added),
|
||||
len(report.updated),
|
||||
len(report.deleted),
|
||||
len(report.skipped),
|
||||
len(report.errors),
|
||||
)
|
||||
|
|
@ -29,6 +29,9 @@ class FlowDefinition(SQLModel, table=True):
|
|||
name: str = Field(index=True, unique=True, max_length=200)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
status: str = Field(default="active", max_length=20) # active | archived
|
||||
# Filename under backend/flows/ this definition is synced from
|
||||
# (e.g. "simple_rule.json"). None for definitions created via the API.
|
||||
source_path: str | None = Field(default=None, max_length=255)
|
||||
# Flow to run after a human approval on this flow's runs (nullable).
|
||||
continuation_flow_id: UUID | None = Field(
|
||||
default=None, foreign_key="flow_definitions.id"
|
||||
|
|
|
|||
1945
backend/flows/approval_handoff.json
Normal file
1945
backend/flows/approval_handoff.json
Normal file
File diff suppressed because one or more lines are too long
1393
backend/flows/simple_rule.json
Normal file
1393
backend/flows/simple_rule.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,32 @@
|
|||
"""add source_path to flow_definitions
|
||||
|
||||
Revision ID: d932b6993f9a
|
||||
Revises: 62ea9a5d8f9d
|
||||
Create Date: 2026-09-07 08:40:48.986026
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd932b6993f9a'
|
||||
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
"flow_definitions",
|
||||
sa.Column("source_path", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column("flow_definitions", "source_path")
|
||||
|
|
@ -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())
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
|
||||
|
|
@ -35,7 +37,9 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert body["definition"]["name"] == "importiert"
|
||||
assert body["version"]["version_no"] == 1
|
||||
assert body["version"]["author"] == "admin"
|
||||
assert body["version"]["lfx_flow_id"] == body["version"]["id"]
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(
|
||||
UUID(body["definition"]["id"]), 1
|
||||
)
|
||||
assert body["version"]["deployed_at"] is not None
|
||||
assert body["version"]["langflow_json"]["data"]["nodes"]
|
||||
|
||||
|
|
@ -48,7 +52,7 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_import_reuses_definition_and_bumps_version(
|
||||
async def test_import_same_content_is_idempotent(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
override_langflow(_export_doc())
|
||||
|
|
@ -61,9 +65,44 @@ async def test_import_reuses_definition_and_bumps_version(
|
|||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
||||
# Unchanged content creates no new version.
|
||||
assert second.json()["version"]["version_no"] == 1
|
||||
|
||||
|
||||
async def test_import_changed_content_bumps_version(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert first.status_code == 201
|
||||
changed = _export_doc()
|
||||
changed["description"] = "neue Beschreibung"
|
||||
override_langflow(changed)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert second.json()["version"]["version_no"] == 2
|
||||
|
||||
|
||||
async def test_import_writes_canonical_file(
|
||||
client, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||
):
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(_export_doc())
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
written = tmp_path / "importiert.json"
|
||||
assert written.is_file()
|
||||
assert json.loads(written.read_text())["name"] == "importiert"
|
||||
|
||||
|
||||
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc(name="langflow-name"))
|
||||
response = await client.post(
|
||||
|
|
|
|||
39
backend/tests/test_export.py
Normal file
39
backend/tests/test_export.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Unit tests for canonical flow files (export.py). No DB or LFX needed."""
|
||||
|
||||
import json
|
||||
|
||||
from app.flows import export
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert export.slugify("Dachsanierung 200k") == "dachsanierung-200k"
|
||||
assert export.slugify(" A/B\\C ") == "a-b-c"
|
||||
assert export.slugify("simple_rule") == "simple-rule"
|
||||
assert export.slugify("") == "flow"
|
||||
|
||||
|
||||
def test_canonical_bytes_stable_under_key_reorder():
|
||||
doc = {"name": "x", "data": {"nodes": [], "edges": []}, "tags": ["b", "a"]}
|
||||
shuffled = {"tags": ["b", "a"], "data": {"edges": [], "nodes": []}, "name": "x"}
|
||||
assert export.canonical_bytes(shuffled) == export.canonical_bytes(doc)
|
||||
assert export.content_hash(doc) == export.content_hash(shuffled)
|
||||
|
||||
|
||||
def test_write_and_read_roundtrip_is_byte_stable(tmp_path):
|
||||
doc = {"name": "My Flow", "data": {"nodes": [], "edges": []}}
|
||||
path = export.write_flow_file(tmp_path, doc)
|
||||
assert path == tmp_path / "my-flow.json"
|
||||
assert json.loads(path.read_text()) == doc
|
||||
# Rewriting identical content is byte-identical (no phantom git diffs).
|
||||
before = path.read_bytes()
|
||||
export.write_flow_file(tmp_path, doc)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_iter_flow_files_only_top_level_json(tmp_path):
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "b.txt").write_text("{}")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "c.json").write_text("{}")
|
||||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
|||
flow = await _create_flow(client, "dachsanierung")
|
||||
await _create_version(client, flow["id"])
|
||||
deployed = await _deploy(client, flow["id"])
|
||||
assert deployed["lfx_flow_id"] == deployed["id"]
|
||||
assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1)
|
||||
assert deployed["deployed_at"] is not None
|
||||
|
||||
response = await client.post(
|
||||
|
|
|
|||
129
backend/tests/test_sync.py
Normal file
129
backend/tests/test_sync.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Sync engine tests: backend/flows/*.json -> DB + LFX (LFX real, DB sqlite)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flows import store, sync
|
||||
from app.flows.lfx_client import LfxClient, LfxNotFound
|
||||
from app.flows.sync import lfx_id_for
|
||||
from app.settings import settings
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
def _lfx() -> LfxClient:
|
||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
|
||||
|
||||
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
|
||||
shutil.copy(FIXTURES / name, tmp_path / name)
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.added == ["simple_rule"]
|
||||
assert report.errors == {}
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert definition is not None
|
||||
assert definition.name == "simple_rule"
|
||||
versions = store.list_versions(session, definition.id)
|
||||
assert [v.version_no for v in versions] == [1]
|
||||
assert versions[0].lfx_flow_id == lfx_id_for(definition.id, 1)
|
||||
assert versions[0].deployed_at is not None
|
||||
|
||||
|
||||
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert first.added == ["simple_rule"]
|
||||
second = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert second.skipped == ["simple_rule"]
|
||||
assert second.added == second.updated == []
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert len(store.list_versions(session, definition.id)) == 1
|
||||
|
||||
|
||||
async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
old_lfx_id = lfx_id_for(definition.id, 1)
|
||||
|
||||
doc = load_fixture("simple_rule.json")
|
||||
doc["description"] = "geaendert"
|
||||
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
|
||||
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.updated == ["simple_rule"]
|
||||
versions = store.list_versions(session, definition.id)
|
||||
assert [v.version_no for v in versions] == [1, 2]
|
||||
assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2)
|
||||
# Superseded LFX registration is gone.
|
||||
with pytest.raises(LfxNotFound):
|
||||
await _lfx().flow_info(old_lfx_id)
|
||||
|
||||
|
||||
async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
version = store.list_versions(session, definition.id)[0]
|
||||
run = store.create_run(session, version, input={"a": 1}, tenant_id="default")
|
||||
lfx_id = lfx_id_for(definition.id, 1)
|
||||
|
||||
(tmp_path / "simple_rule.json").unlink()
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.deleted == ["simple_rule"]
|
||||
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
|
||||
with pytest.raises(store.NotFoundError):
|
||||
store.get_run(session, run.id)
|
||||
with pytest.raises(LfxNotFound):
|
||||
await _lfx().flow_info(lfx_id)
|
||||
|
||||
|
||||
async def test_sync_reports_invalid_json(session, lfx_server, tmp_path):
|
||||
(tmp_path / "kaputt.json").write_text("{kein json")
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert "kaputt.json" in report.errors
|
||||
assert report.added == []
|
||||
assert store.list_definitions(session) == []
|
||||
|
||||
|
||||
async def test_sync_missing_dir_raises(session, lfx_server, tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path / "gibts-nicht")
|
||||
|
||||
|
||||
async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
doc = load_fixture("simple_rule.json")
|
||||
doc["name"] = "umbenannt"
|
||||
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.updated == ["simple_rule"]
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert definition.name == "umbenannt"
|
||||
|
||||
|
||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
_seed_dir(tmp_path)
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["added"] == ["simple_rule"]
|
||||
assert body["errors"] == {}
|
||||
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.json()["skipped"] == ["simple_rule"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue