refactor: tighten backend, flows, frontend, tests; push flows to Langflow

This commit is contained in:
Jonas Miederer 2026-09-28 15:58:00 +02:00
parent 6688fb08c6
commit 06ab25fb47
31 changed files with 783 additions and 358 deletions

View file

@ -12,30 +12,24 @@ from __future__ import annotations
import json
import logging
import secrets
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Annotated
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin
from app.db import get_engine, get_session
from app.db import get_db
from app.models import User
from app.settings import settings
from . import input_schema as form_schema
from . import runner, store
from .deploy import import_from_langflow, pull_all_from_langflow
from .langflow_client import (
LangflowClient,
LangflowError,
LangflowNotFound,
LangflowUnavailable,
)
from .deploy import import_from_langflow
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
from .lfx_client import (
LfxClient,
LfxConflict,
@ -60,7 +54,7 @@ from .schemas import (
RunRequest,
SyncReport,
)
from .sync import lfx_id_for, sync_from_directory
from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
@ -68,10 +62,6 @@ approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__)
def get_db() -> Iterator[Session]:
yield from get_session()
def get_lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
@ -81,60 +71,40 @@ def get_langflow() -> LangflowClient:
def _read_definition(d) -> FlowDefinitionRead:
return FlowDefinitionRead(
id=d.id,
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,
)
return FlowDefinitionRead.model_validate(d, from_attributes=True)
def _read_version(v) -> FlowVersionRead:
return FlowVersionRead(
id=v.id,
flow_id=v.flow_id,
version_no=v.version_no,
langflow_json=v.langflow_json,
lfx_flow_id=v.lfx_flow_id,
deployed_at=v.deployed_at,
author=v.author,
created_at=v.created_at,
input_schema=v.input_schema,
)
return FlowVersionRead.model_validate(v, from_attributes=True)
def _read_run(r) -> RunRead:
return RunRead(
id=r.id,
version_id=r.version_id,
tenant_id=r.tenant_id,
status=r.status,
input=r.input,
output=r.output,
session_id=r.session_id,
created_at=r.created_at,
finished_at=r.finished_at,
)
return RunRead.model_validate(r, from_attributes=True)
def _read_task(t) -> ApprovalRead:
return ApprovalRead(
id=t.id,
run_id=t.run_id,
version_id=t.version_id,
tenant_id=t.tenant_id,
title=t.title,
payload=t.payload,
continuation_flow_id=t.continuation_flow_id,
approver=t.approver,
status=t.status,
decision_note=t.decision_note,
created_at=t.created_at,
decided_at=t.decided_at,
)
return ApprovalRead.model_validate(t, from_attributes=True)
def _lfx_http(exc: LfxError) -> HTTPException:
"""Map LFX domain errors to HTTP (single place, was 10x duplicated)."""
if isinstance(exc, LfxInvalidFlow):
return HTTPException(status_code=422, detail=str(exc))
if isinstance(exc, LfxConflict):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, LfxUnavailable):
logger.warning("lfx unavailable: %s", exc)
return HTTPException(status_code=503, detail="flow runtime unavailable")
logger.warning("lfx error: %s", exc)
return HTTPException(status_code=502, detail="flow runtime error")
def _langflow_http(exc: LangflowError) -> HTTPException:
if isinstance(exc, LangflowUnavailable):
logger.warning("langflow unavailable: %s", exc)
return HTTPException(status_code=503, detail="authoring server unavailable")
logger.warning("langflow error: %s", exc)
return HTTPException(status_code=502, detail="authoring server error")
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
@ -164,21 +134,14 @@ async def sync_flows(
_admin: Annotated[User, Depends(require_admin)],
):
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
from .sync import sync_full_pass
try:
pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir))
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir))
except LangflowError as exc:
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
try:
report = await sync_from_directory(session, lfx, Path(settings.flows_dir))
raise _langflow_http(exc) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
report.pulled = pulled_result["pulled"]
report.errors = {**pulled_result["errors"], **report.errors}
return report
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
@ -236,16 +199,8 @@ async def deploy_version(
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
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
raise _lfx_http(exc) from exc
return _read_version(store.mark_deployed(session, version, uploaded.id))
@ -290,69 +245,35 @@ async def run_flow(
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
raise _lfx_http(exc) from exc
return _read_run(run)
async def _run_stream(
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
):
if not version.lfx_flow_id:
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
run = store.create_run(
session,
version,
input=data.input,
tenant_id=user.tenant_id,
session_id=data.session_id,
)
envelope = runner.build_envelope(
payload=data.input,
run_id=run.id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
fields=form_schema.schema_field_names(
version.input_schema or form_schema.extract_form_schema(version.langflow_json)
),
)
try:
run, envelope, lfx_flow_id, sid = await runner.stream_run(
session,
lfx,
version,
payload=data.input,
tenant_id=user.tenant_id,
session_id=data.session_id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
)
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
run_id = run.id
lfx_flow_id = version.lfx_flow_id
session_id = data.session_id or str(run.id)
async def event_gen() -> AsyncIterator[str]:
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
try:
async with (
httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client,
client.stream(
"POST",
f"/flows/{lfx_flow_id}/stream",
json={"input_value": envelope, "session_id": session_id},
) as response,
):
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
async for chunk in response.aiter_text():
yield chunk
except (LfxNotFound, httpx.HTTPError) as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
# Finalize from a fresh session: the request session may be closed.
with Session(get_engine()) as fresh:
try:
fresh_run = store.get_run(fresh, run_id)
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
except store.NotFoundError:
status = "failed"
async for chunk in runner.stream_lfx_chunks(lfx, lfx_flow_id, envelope, sid):
yield chunk
status = runner.finalize_streamed_run(run_id)
yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n"
return StreamingResponse(event_gen(), media_type="text/event-stream")
@ -379,12 +300,8 @@ async def list_langflow_flows(
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
raise _langflow_http(exc) from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
@ -408,26 +325,14 @@ async def import_flow(
)
except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
raise _langflow_http(exc) from exc
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxInvalidFlow as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
raise _lfx_http(exc) from exc
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
@ -564,10 +469,8 @@ async def decide_approval(
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
raise _lfx_http(exc) from exc
return DecisionRead(
task=_read_task(task),
continuation_run=_read_run(continuation_run) if continuation_run else None,