"""Flows + approvals HTTP API.""" from __future__ import annotations import json from collections.abc import AsyncIterator, Iterator from uuid import UUID import httpx from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlmodel import Session from app.db import get_engine, get_session from app.settings import settings from . import runner, store from .lfx_client import ( LfxClient, LfxConflict, LfxError, LfxInvalidFlow, LfxNotFound, LfxRunFailed, LfxUnavailable, ) from .schemas import ( ApprovalCreate, ApprovalRead, FlowDefinitionCreate, FlowDefinitionRead, FlowVersionCreate, FlowVersionRead, RunRead, RunRequest, ) flows_router = APIRouter(prefix="/api/flows", tags=["flows"]) approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"]) 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) def _read_definition(d) -> FlowDefinitionRead: return FlowDefinitionRead( id=d.id, name=d.name, description=d.description, status=d.status, continuation_flow_id=d.continuation_flow_id, created_at=d.created_at, ) 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, ) 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, ) 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, ) @flows_router.post("", response_model=FlowDefinitionRead, status_code=201) def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)): 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: Session = Depends(get_db)): return [_read_definition(d) for d in store.list_definitions(session)] @flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) def get_flow(flow_id: UUID, session: 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: Session = Depends(get_db) ): 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: Session = Depends(get_db)): 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: Session = Depends(get_db), lfx: LfxClient = Depends(get_lfx), ): 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: uploaded = await lfx.upload_flow( version.langflow_json, flow_id=str(version.id), 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: raise HTTPException(status_code=503, detail=str(exc)) from exc except LfxError as exc: raise HTTPException(status_code=502, detail=str(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, stream: bool = Query(default=False), session: Session = Depends(get_db), lfx: LfxClient = Depends(get_lfx), ): try: 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) try: run = await runner.execute_run( session, lfx, version, payload=data.input, tenant_id=data.tenant_id, session_id=data.session_id, callback_base_url=data.callback_base_url or settings.callback_base_url, continuation_flow_id=data.continuation_flow_id, ) 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 return _read_run(run) async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest): 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=data.tenant_id, session_id=data.session_id, ) envelope = runner.build_envelope( payload=data.input, run_id=run.id, callback_base_url=data.callback_base_url or settings.callback_base_url, continuation_flow_id=data.continuation_flow_id, ) 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: async with 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" 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: Session = Depends(get_db)): try: return [_read_run(r) for r in store.list_runs(session, flow_id)] except store.NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @flows_router.get("/runs/{run_id}", response_model=RunRead) def get_run(run_id: UUID, session: Session = Depends(get_db)): try: return _read_run(store.get_run(session, run_id)) except store.NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @approvals_router.post("", response_model=ApprovalRead, status_code=201) def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)): """Flow callback contract: report a terminal approval state for a run.""" 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( tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), ): return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]