kirche-einfach/backend/app/flows/api.py

477 lines
17 KiB
Python

"""Flows + approvals HTTP API.
Auth model: flow *management* (create/version/deploy/sync/import) is
admin-only. Browsing (list/get/input-schema) is public so the website
renders. Runs, run reads and approval reads require login and are scoped
to the session tenant. Flow *callbacks* (LFX has no session) carry a
shared X-Callback-Token instead.
"""
from __future__ import annotations
import json
import logging
import secrets
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Annotated
from uuid import UUID
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_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
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
from .lfx_client import (
LfxClient,
LfxConflict,
LfxError,
LfxInvalidFlow,
LfxNotFound,
LfxUnavailable,
)
from .schemas import (
ApprovalCreate,
ApprovalRead,
DecisionRead,
DecisionRequest,
FlowDefinitionCreate,
FlowDefinitionRead,
FlowImportRead,
FlowImportRequest,
FlowVersionCreate,
FlowVersionRead,
InputSchemaRead,
RunRead,
RunRequest,
SyncReport,
)
from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__)
def get_lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
def get_langflow() -> LangflowClient:
return LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key or "")
def _read_definition(d) -> FlowDefinitionRead:
return FlowDefinitionRead.model_validate(d, from_attributes=True)
def _read_version(v) -> FlowVersionRead:
return FlowVersionRead.model_validate(v, from_attributes=True)
def _read_run(r) -> RunRead:
return RunRead.model_validate(r, from_attributes=True)
def _read_task(t) -> ApprovalRead:
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)
def create_flow(
data: FlowDefinitionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_definition(store.create_definition(session, data))
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
@flows_router.get("", response_model=list[FlowDefinitionRead])
def list_flows(session: Annotated[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: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
langflow: Annotated[LangflowClient, Depends(get_langflow)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
from .sync import sync_full_pass
try:
return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir))
except LangflowError as exc:
raise _langflow_http(exc) from exc
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: Annotated[Session, Depends(get_db)]):
try:
return _read_definition(store.get_definition(session, flow_id))
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
def create_version(
flow_id: UUID,
data: FlowVersionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_version(store.create_version(session, flow_id, data))
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
def list_versions(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return [_read_version(v) for v in store.list_versions(session, flow_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post("/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead)
async def deploy_version(
flow_id: UUID,
version_no: int,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
version = store.get_version_by_no(session, flow_id, version_no)
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=lfx_id_for(flow_id, version_no),
replace=True,
)
except LfxError as exc:
raise _lfx_http(exc) from exc
return _read_version(store.mark_deployed(session, version, uploaded.id))
def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
if version_no is not None:
return store.get_version_by_no(session, flow_id, version_no)
return store.get_latest_deployed_version(session, flow_id)
@flows_router.post("/{flow_id}/run", response_model=RunRead)
async def run_flow(
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User, Depends(get_current_user)],
stream: bool = Query(default=False),
):
# Tenant, callback URL and continuation always come from the server:
# the session, the settings, and the admin-authored definition.
tenant_id = user.tenant_id
try:
definition = store.get_definition(session, flow_id)
version = _resolve_version(session, flow_id, data.version_no)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if stream:
return await _run_stream(session, lfx, version, data, user, definition)
try:
run = await runner.execute_run(
session,
lfx,
version,
payload=data.input,
tenant_id=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 LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxError as 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
):
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
async def event_gen() -> AsyncIterator[str]:
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
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")
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
# Strict tenant scoping: the session decides, query params cannot widen.
try:
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.get("/langflow/available")
async def list_langflow_flows(
langflow: Annotated[LangflowClient, Depends(get_langflow)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
except LangflowError as exc:
raise _langflow_http(exc) from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
async def import_flow(
data: FlowImportRequest,
session: Annotated[Session, Depends(get_db)],
langflow: Annotated[LangflowClient, Depends(get_langflow)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
try:
definition, version = await import_from_langflow(
session,
langflow,
lfx,
data.langflow_flow_id,
name=data.name,
author=data.author,
deploy=data.deploy,
)
except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowError as 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 LfxError as exc:
raise _lfx_http(exc) from exc
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
def get_input_schema(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
):
"""Form schema of the latest deployed version (404 when none)."""
try:
version = store.get_latest_deployed_version(session, flow_id)
except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
return InputSchemaRead(
flow_id=flow_id,
version_no=version.version_no,
json_schema=cached.get("json_schema", {}),
ui_schema=cached.get("ui_schema", {}),
fallback=cached.get("fallback"),
)
@flows_router.get("/runs/{run_id}", response_model=RunRead)
def get_run(
run_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
try:
run = store.get_run(session, run_id)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if run.tenant_id != user.tenant_id:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
return _read_run(run)
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
def _check_callback_token(token: str | None) -> None:
"""LFX-served flows prove they carry our run envelope (no session there)."""
expected = settings.callback_token
if not expected:
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
if not token or not secrets.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="invalid callback token")
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
def create_approval(
data: ApprovalCreate,
session: Annotated[Session, Depends(get_db)],
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
):
"""Flow callback contract: report a terminal approval state for a run."""
_check_callback_token(callback_token)
try:
return _read_task(store.create_approval_task(session, data))
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(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
@approvals_router.get("", response_model=list[ApprovalRead])
def list_approvals(
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
async def decide_approval(
task_id: UUID,
data: DecisionRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User, Depends(get_current_user)],
):
"""Decide an open task. Approve starts the continuation flow (if any).
Identity comes from the session: the approver is the logged-in user,
and the continuation is the task's own (never client-chosen).
"""
try:
task = store.get_task(session, task_id)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# Resolve the continuation first: bad references fail before any state change.
continuation_version = None
if data.decision == "approve" and task.continuation_flow_id is not None:
try:
continuation_version = store.get_latest_deployed_version(
session, task.continuation_flow_id
)
except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
task = store.decide_task(
session,
task,
decision="approved" if data.decision == "approve" else "rejected",
approver=user.email,
note=data.note,
)
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Note: the decision is committed before the continuation runs. If LFX is
# down, the task stays decided and the continuation can be started
# manually from the preserved task payload.
continuation_run = None
if continuation_version is not None:
payload = task.payload
if isinstance(payload.get("facts"), dict):
payload = payload["facts"]
try:
continuation_run = await runner.execute_run(
session,
lfx,
continuation_version,
payload=payload,
tenant_id=task.tenant_id,
session_id=f"continuation-of-{task.run_id}",
callback_base_url=settings.callback_base_url,
callback_token=settings.callback_token,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxError as exc:
raise _lfx_http(exc) from exc
return DecisionRead(
task=_read_task(task),
continuation_run=_read_run(continuation_run) if continuation_run else None,
)