feat: flow runtime on LFX with tests

- LfxClient (upload/run/info/delete, typed errors, serve-key auth)
- Flows API: CRUD, versions, deploy, sync run, SSE stream proxy, runs
- Approvals callback endpoint (flow terminal-state contract)
- Runner: envelope in, status from approval side-effect (no interrupts)
- Fixture generator (lfx serializers) + simple_rule/approval_handoff
  fixtures verified against real lfx serve
- 14 tests green (real LFX subprocess + live uvicorn callback server)
This commit is contained in:
Jonas Miederer 2026-09-06 22:47:45 +00:00
parent e178ae406d
commit 96c0da33c7
15 changed files with 5070 additions and 5 deletions

View file

@ -68,6 +68,8 @@ services:
build:
context: .
dockerfile: Dockerfile.lfx
env_file:
- ../.env
volumes:
- lfx-data:/flows
ports:

View file

@ -19,6 +19,10 @@ LANGFLOW_DATABASE_URL=postgresql://kirche:<password>@db:5432/langflow
# --- lfx (flow runtime) ---
LFX_URL=http://lfx:6004
LFX_API_KEY=lfx-
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
CALLBACK_BASE_URL=http://app:8000
# --- LLM (optional; tests mock the provider without it) ---
# LLM_API_KEY=

View file

311
backend/app/flows/api.py Normal file
View file

@ -0,0 +1,311 @@
"""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)]

View file

@ -0,0 +1,184 @@
"""Async client for the LFX flow runtime (stateless `lfx serve` process)."""
from __future__ import annotations
from dataclasses import dataclass
import httpx
class LfxError(Exception):
"""Base error for LFX communication problems."""
class LfxNotFound(LfxError):
"""Flow id unknown to LFX (HTTP 404)."""
class LfxInvalidFlow(LfxError):
"""Flow JSON rejected by LFX (HTTP 422)."""
class LfxConflict(LfxError):
"""Flow id already registered without replace=true (HTTP 409)."""
class LfxRunFailed(LfxError):
"""Flow execution failed inside LFX (HTTP 500 or success=false)."""
class LfxUnavailable(LfxError):
"""LFX process unreachable."""
@dataclass
class LfxUploadResult:
id: str
name: str
description: str | None
run_url: str
@dataclass
class LfxRunResult:
result: str
success: bool
logs: str
type: str
component: str
class LfxClient:
"""Thin async wrapper around the LFX serve HTTP API."""
def __init__(
self, base_url: str, timeout: float = 120.0, api_key: str | None = None
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.api_key = api_key
def _headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)
async def health(self) -> dict:
try:
async with self._client() as client:
response = await client.get("/health")
response.raise_for_status()
return response.json()
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
async def list_flows(self) -> list[dict]:
try:
async with self._client() as client:
response = await client.get("/flows")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
raise self._mapped(exc) from exc
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
async def upload_flow(
self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False
) -> LfxUploadResult:
"""Register a Langflow-export-format flow JSON. Returns the LFX flow id."""
data = flow_json.get("data")
if not isinstance(data, dict):
raise LfxInvalidFlow("flow JSON must contain a 'data' object")
body = {
"name": flow_json.get("name", "flow"),
"description": flow_json.get("description"),
"data": data,
"replace": replace,
}
if flow_id is not None:
body["id"] = flow_id
try:
async with self._client() as client:
response = await client.post("/flows/upload/", json=body)
response.raise_for_status()
payload = response.json()
return LfxUploadResult(
id=payload["id"],
name=payload.get("name", ""),
description=payload.get("description"),
run_url=payload.get("run_url", ""),
)
except httpx.HTTPStatusError as exc:
raise self._mapped(exc) from exc
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
async def run_flow(
self,
lfx_flow_id: str,
*,
input_value: str,
session_id: str | None = None,
global_vars: dict[str, str] | None = None,
) -> LfxRunResult:
"""Execute a flow synchronously; raises on failure."""
body: dict = {"input_value": input_value}
if session_id is not None:
body["session_id"] = session_id
if global_vars is not None:
body["global_vars"] = global_vars
try:
async with self._client() as client:
response = await client.post(f"/flows/{lfx_flow_id}/run", json=body)
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
data = response.json()
result = LfxRunResult(
result=data.get("result", ""),
success=bool(data.get("success", False)),
logs=data.get("logs", ""),
type=data.get("type", ""),
component=data.get("component", ""),
)
if response.status_code >= 400 or not result.success:
raise LfxRunFailed(f"LFX run failed: {result.result}")
return result
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
async def flow_info(self, lfx_flow_id: str) -> dict:
try:
async with self._client() as client:
response = await client.get(f"/flows/{lfx_flow_id}/info")
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
return response.json()
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
async def delete_flow(self, lfx_flow_id: str) -> None:
try:
async with self._client() as client:
response = await client.delete(f"/flows/{lfx_flow_id}")
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@staticmethod
def _mapped(exc: httpx.HTTPStatusError) -> LfxError:
status = exc.response.status_code
detail = exc.response.text[:500]
if status == 404:
return LfxNotFound(detail)
if status == 409:
return LfxConflict(detail)
if status == 422:
return LfxInvalidFlow(detail)
return LfxError(f"LFX error {status}: {detail}")

View file

@ -0,0 +1,93 @@
"""Run orchestration: our DB rows + LFX execution + terminal-state mapping."""
from __future__ import annotations
import json
from uuid import UUID
from sqlmodel import Session, select
from app.models import ApprovalTask, FlowRun, FlowVersion
from . import store
from .lfx_client import LfxClient, LfxRunFailed
from .store import InvalidError
def build_envelope(
*,
payload: dict,
run_id: UUID,
callback_base_url: str,
continuation_flow_id: UUID | None,
) -> str:
return json.dumps(
{
"payload": payload,
"run_id": str(run_id),
"callback_url": callback_base_url.rstrip("/"),
"continuation_flow_id": (
str(continuation_flow_id) if continuation_flow_id else None
),
}
)
def _task_exists(session: Session, run_id: UUID) -> bool:
return (
session.exec(
select(ApprovalTask).where(
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
)
).first()
is not None
)
async def execute_run(
session: Session,
lfx: LfxClient,
version: FlowVersion,
*,
payload: dict,
tenant_id: str = "default",
session_id: str | None = None,
callback_base_url: str,
continuation_flow_id: UUID | None = None,
) -> FlowRun:
"""Run a deployed version to a terminal state (no interrupts).
The flow always terminates: either it completes, or its approval branch
POSTs back to /api/approvals (creating a task) and terminates with a
pending marker. Status is derived from that side effect, not from
result-text parsing.
"""
if not version.lfx_flow_id:
raise InvalidError(f"version {version.id} is not deployed to LFX")
run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
)
envelope = build_envelope(
payload=payload,
run_id=run.id,
callback_base_url=callback_base_url,
continuation_flow_id=continuation_flow_id,
)
try:
result = await lfx.run_flow(
version.lfx_flow_id,
input_value=envelope,
session_id=session_id or str(run.id),
)
except LfxRunFailed as exc:
return store.set_run_result(
session, run, "failed", {"error": str(exc)}
)
output = {
"result": result.result,
"component": result.component,
"type": result.type,
}
if _task_exists(session, run.id):
return store.set_run_result(session, run, "pending_approval", output)
return store.set_run_result(session, run, "completed", output)

View file

@ -0,0 +1,86 @@
"""Pydantic contracts for the flows API."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class FlowDefinitionCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=2000)
continuation_flow_id: UUID | None = None
class FlowDefinitionRead(BaseModel):
id: UUID
name: str
description: str
status: str
continuation_flow_id: UUID | None
created_at: datetime
class FlowVersionCreate(BaseModel):
langflow_json: dict
author: str = Field(default="", max_length=200)
class FlowVersionRead(BaseModel):
id: UUID
flow_id: UUID
version_no: int
langflow_json: dict
lfx_flow_id: str | None
deployed_at: datetime | None
author: str
created_at: datetime
class RunRequest(BaseModel):
"""Start a flow run. `input` is the domain payload handed to the flow."""
input: dict = Field(default_factory=dict)
version_no: int | None = None
tenant_id: str = Field(default="default", max_length=200)
session_id: str | None = None
# Base URL the flow uses for callbacks (e.g. approval tasks).
# Defaults to settings.callback_base_url; tests point it at a live server.
callback_base_url: str | None = None
continuation_flow_id: UUID | None = None
class RunRead(BaseModel):
id: UUID
version_id: UUID
tenant_id: str
status: str
input: dict | None
output: dict | None
session_id: str | None
created_at: datetime
finished_at: datetime | None
class ApprovalCreate(BaseModel):
"""Callback contract: a flow reports a terminal approval state."""
run_id: UUID
title: str = Field(default="", max_length=500)
payload: dict = Field(default_factory=dict)
continuation_flow_id: UUID | None = None
class ApprovalRead(BaseModel):
id: UUID
run_id: UUID
version_id: UUID
tenant_id: str
title: str
payload: dict
continuation_flow_id: UUID | None
approver: str | None
status: str
decision_note: str
created_at: datetime
decided_at: datetime | None

225
backend/app/flows/store.py Normal file
View file

@ -0,0 +1,225 @@
"""Persistence operations for flow definitions, versions, runs, and tasks."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, func, select
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
class NotFoundError(Exception):
pass
class ConflictError(Exception):
pass
class InvalidError(Exception):
pass
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
raise InvalidError("continuation_flow_id unknown")
definition = FlowDefinition(
name=data.name,
description=data.description,
continuation_flow_id=data.continuation_flow_id,
)
session.add(definition)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise ConflictError(f"flow {data.name!r} already exists") from exc
session.refresh(definition)
return definition
def get_definition(session: Session, flow_id: UUID) -> FlowDefinition:
definition = session.get(FlowDefinition, flow_id)
if definition is None:
raise NotFoundError(f"flow {flow_id} not found")
return definition
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:
if not isinstance(flow_json, dict):
raise InvalidError("langflow_json must be an object")
data = flow_json.get("data")
if not isinstance(data, dict):
raise InvalidError("langflow_json must contain a 'data' object")
if not isinstance(data.get("nodes"), list) or not isinstance(data.get("edges"), list):
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
def create_version(
session: Session, flow_id: UUID, data: FlowVersionCreate
) -> FlowVersion:
get_definition(session, flow_id)
_validate_flow_json(data.langflow_json)
max_no = session.exec(
select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id)
).one()
version = FlowVersion(
flow_id=flow_id,
version_no=(max_no or 0) + 1,
langflow_json=data.langflow_json,
author=data.author,
)
session.add(version)
session.commit()
session.refresh(version)
return version
def get_version(session: Session, version_id: UUID) -> FlowVersion:
version = session.get(FlowVersion, version_id)
if version is None:
raise NotFoundError(f"flow version {version_id} not found")
return version
def list_versions(session: Session, flow_id: UUID) -> list[FlowVersion]:
get_definition(session, flow_id)
return list(
session.exec(
select(FlowVersion)
.where(FlowVersion.flow_id == flow_id)
.order_by(FlowVersion.version_no)
).all()
)
def get_version_by_no(session: Session, flow_id: UUID, version_no: int) -> FlowVersion:
version = session.exec(
select(FlowVersion).where(
FlowVersion.flow_id == flow_id, FlowVersion.version_no == version_no
)
).first()
if version is None:
raise NotFoundError(f"flow {flow_id} has no version {version_no}")
return version
def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
version = session.exec(
select(FlowVersion)
.where(FlowVersion.flow_id == flow_id, FlowVersion.deployed_at.is_not(None))
.order_by(FlowVersion.version_no.desc())
).first()
if version is None:
raise InvalidError(f"flow {flow_id} has no deployed version")
return version
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
version.lfx_flow_id = lfx_flow_id
version.deployed_at = datetime.now(timezone.utc)
session.add(version)
session.commit()
session.refresh(version)
return version
def create_run(
session: Session,
version: FlowVersion,
*,
input: dict | None,
tenant_id: str = "default",
session_id: str | None = None,
) -> FlowRun:
run = FlowRun(
version_id=version.id,
tenant_id=tenant_id,
input=input,
session_id=session_id or str(version.id),
)
session.add(run)
session.commit()
session.refresh(run)
return run
def set_run_result(
session: Session, run: FlowRun, status: str, output: dict | None
) -> FlowRun:
run.status = status
run.output = output
run.finished_at = datetime.now(timezone.utc)
session.add(run)
session.commit()
session.refresh(run)
return run
def get_run(session: Session, run_id: UUID) -> FlowRun:
run = session.get(FlowRun, run_id)
if run is None:
raise NotFoundError(f"run {run_id} not found")
return run
def list_runs(session: Session, flow_id: UUID) -> list[FlowRun]:
get_definition(session, flow_id)
return list(
session.exec(
select(FlowRun)
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
.where(FlowVersion.flow_id == flow_id)
.order_by(FlowRun.created_at.desc())
).all()
)
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
run = session.get(FlowRun, data.run_id)
if run is None:
raise NotFoundError(f"run {data.run_id} not found")
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
raise InvalidError("continuation_flow_id unknown")
existing = session.exec(
select(ApprovalTask).where(
ApprovalTask.run_id == data.run_id, ApprovalTask.status == "open"
)
).first()
if existing is not None:
raise ConflictError(f"run {data.run_id} already has an open approval task")
task = ApprovalTask(
run_id=run.id,
version_id=run.version_id,
tenant_id=run.tenant_id,
title=data.title,
payload=data.payload,
continuation_flow_id=data.continuation_flow_id,
)
session.add(task)
run.status = "pending_approval"
run.finished_at = datetime.now(timezone.utc)
session.add(run)
session.commit()
session.refresh(task)
return task
def list_approval_tasks(session: Session, tenant_id: str | None = None) -> list[ApprovalTask]:
query = select(ApprovalTask).order_by(ApprovalTask.created_at.desc())
if tenant_id is not None:
query = query.where(ApprovalTask.tenant_id == tenant_id)
return list(session.exec(query).all())

View file

@ -1,5 +1,6 @@
from fastapi import FastAPI
from .flows.api import approvals_router, flows_router
from .settings import settings
app = FastAPI(
@ -11,6 +12,9 @@ app = FastAPI(
openapi_url="/api/openapi.json",
)
app.include_router(flows_router)
app.include_router(approvals_router)
@app.get("/")
async def root():

View file

@ -21,7 +21,11 @@ class Settings(BaseSettings):
langflow_url: str = "http://localhost:7860"
langflow_api_key: str = ""
lfx_url: str = "http://localhost:6004"
lfx_api_key: str = ""
llm_api_key: str = ""
# Base URL flows use for callbacks into this API. Must be reachable
# FROM the LFX process (compose: http://app:8000).
callback_base_url: str = "http://localhost:8000"
settings = Settings()

View file

@ -0,0 +1,444 @@
"""Generate Langflow-export-format flow JSON fixtures.
Uses lfx's own serializers (Component.to_frontend_node /
build_custom_component_template), so the output is byte-faithful to what the
Langflow UI would export. Run with the backend venv (has the `lfx` dev dep)::
uv run python scripts/make_flow_fixtures.py
Then verify::
uv run lfx validate --level 4 backend/tests/fixtures/flows/
"""
from __future__ import annotations
import importlib
import json
import sys
import uuid
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
FIXTURE_DIR = REPO_ROOT / "backend" / "tests" / "fixtures" / "flows"
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
# --------------------------------------------------------------------------
# node builders
# --------------------------------------------------------------------------
def _normalize(node_dict: dict, node_id: str, comp_name: str, x: int, y: int) -> dict:
# to_frontend_node() returns an envelope {"data": {...}, "id": ...};
# build_custom_component_template() returns the bare node dict.
if "data" not in node_dict:
node_dict = {
"data": {"node": node_dict, "type": comp_name, "id": node_id},
"id": node_id,
}
else:
node_dict["id"] = node_id
node_dict["data"]["id"] = node_id
node_dict["data"]["type"] = comp_name
node_dict["data"]["showNode"] = True
node_dict["measured"] = {"width": 320, "height": 200}
node_dict["position"] = {"x": x, "y": y}
node_dict["type"] = "genericNode"
return node_dict
def builtin_node(
module_path: str,
class_name: str,
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
) -> dict:
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
inst = cls()
node_dict = _normalize(inst.to_frontend_node(), node_id, cls.name, *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
return node_dict
def custom_node(
code: str,
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
) -> dict:
from lfx.custom import validate as validate_mod
from lfx.custom.utils import build_custom_component_template
cls = validate_mod.create_class(code, validate_mod.extract_class_name(code))
inst = cls()
inst._code = code
bare, _ = build_custom_component_template(inst)
node_dict = _normalize(bare, node_id, "CustomComponent", *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
return node_dict
# --------------------------------------------------------------------------
# edges
# --------------------------------------------------------------------------
def _output_def(node: dict, output_name: str) -> dict:
for out in node["data"]["node"]["outputs"]:
if out["name"] == output_name:
return out
raise KeyError(f"output {output_name!r} not in {node['id']}")
def _input_def(node: dict, input_name: str) -> dict:
field = node["data"]["node"]["template"][input_name]
return field
def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
out = _output_def(src, src_output)
inp = _input_def(dst, dst_input)
source_handle = {
"dataType": src["data"]["type"],
"id": src["id"],
"name": src_output,
"output_types": out.get("types", []),
}
target_handle = {
"fieldName": dst_input,
"id": dst["id"],
"inputTypes": inp.get("input_types", []),
"type": inp.get("type", "other"),
}
sh = json.dumps(source_handle).replace('"', HANDLE_QUOTE)
th = json.dumps(target_handle).replace('"', HANDLE_QUOTE)
return {
"animated": False,
"className": "",
"data": {"sourceHandle": source_handle, "targetHandle": target_handle},
"id": f"xy-edge__{sh}-{th}",
"selected": False,
"source": src["id"],
"sourceHandle": sh,
"target": dst["id"],
"targetHandle": th,
}
# --------------------------------------------------------------------------
# flow document
# --------------------------------------------------------------------------
def flow_doc(
name: str, description: str, nodes: list[dict], edges: list[dict]
) -> dict:
return {
"data": {
"nodes": nodes,
"edges": edges,
"viewport": {"x": 0, "y": 0, "zoom": 1},
},
"description": description,
"endpoint_name": None,
"icon": None,
"icon_bg_color": None,
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{name}")),
"is_component": False,
"locked": False,
"mcp_enabled": False,
"name": name,
"tags": [],
"webhook": False,
}
# --------------------------------------------------------------------------
# custom component code
# --------------------------------------------------------------------------
RULE_CHECK_CODE = '''from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
class RuleCheckComponent(Component):
display_name = "Rule Check"
description = "Bewertet Betrag gegen Schwelle aus JSON-Eingabe."
inputs = [
MessageTextInput(name="payload", display_name="Payload"),
]
outputs = [
Output(display_name="Verdict", name="verdict", method="check"),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def check(self) -> Message:
try:
data = json.loads(self._text(self.payload) or "{}")
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
# Die API ruft Flows immer mit Huelle auf
# {"payload": {...}, "run_id": ..., ...}; rohe Payloads gehen auch.
if isinstance(data.get("payload"), dict):
data = data["payload"]
try:
betrag = float(data.get("betrag", 0) or 0)
except (TypeError, ValueError):
betrag = 0.0
try:
schwelle = float(data.get("schwelle", 200000) or 200000)
except (TypeError, ValueError):
schwelle = 200000.0
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
'''
ENVELOPE_CODE = '''from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
class EnvelopeComponent(Component):
display_name = "Envelope"
description = "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext."
inputs = [
MessageTextInput(name="raw", display_name="Raw Input"),
]
outputs = [
Output(display_name="Payload", name="payload", method="get_payload"),
Output(display_name="Run ID", name="run_id", method="get_run_id"),
Output(
display_name="Callback URL",
name="callback_url",
method="get_callback_url",
),
Output(
display_name="Continuation",
name="continuation",
method="get_continuation",
),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _env(self) -> dict:
if not hasattr(self, "_env_cache"):
try:
self._env_cache = json.loads(self._text(self.raw) or "{}")
except Exception:
self._env_cache = {}
if not isinstance(self._env_cache, dict):
self._env_cache = {}
return self._env_cache
def get_payload(self) -> Message:
return Message(text=json.dumps(self._env().get("payload", {})))
def get_run_id(self) -> Message:
return Message(text=str(self._env().get("run_id", "")))
def get_callback_url(self) -> Message:
return Message(text=str(self._env().get("callback_url", "")))
def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "")
'''
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
class ApprovalCallbackComponent(Component):
display_name = "Approval Callback"
description = "Meldet einen Sonderfall per HTTP an unsere API und terminiert."
inputs = [
MessageTextInput(name="trigger", display_name="Trigger"),
MessageTextInput(name="run_id", display_name="Run ID"),
MessageTextInput(name="callback_url", display_name="Callback URL"),
MessageTextInput(
name="continuation_flow_id",
display_name="Continuation Flow ID",
),
MessageTextInput(name="facts", display_name="Facts"),
]
outputs = [
Output(display_name="Result", name="result", method="notify"),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def notify(self) -> Message:
import httpx
import json
trigger = self._text(self.trigger).strip()
if not trigger:
# Zweig wurde nicht genommen: kein Seiteneffekt.
return Message(text="")
try:
facts = json.loads(self._text(self.facts) or "{}")
except Exception:
facts = {}
url = self._text(self.callback_url).rstrip("/") + "/api/approvals"
body = {
"run_id": self._text(self.run_id),
"title": f"Freigabe erforderlich ({trigger})",
"payload": {"verdict": trigger, "facts": facts},
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
}
try:
response = httpx.post(url, json=body, timeout=10.0)
response.raise_for_status()
except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}")
return Message(text="PENDING_HUMAN_APPROVAL")
'''
# --------------------------------------------------------------------------
# flows
# --------------------------------------------------------------------------
ROUTER_VALUES = {
"operator": "equals",
"match_text": "PRUEFUNG",
"true_case_message": "Sonderfall: menschliche Pruefung erforderlich",
"false_case_message": "Standardfall: automatisch freigegeben",
}
def build_simple_rule() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(400, 200))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",
"ConditionalRouterComponent",
"ConditionalRouter-router",
values=ROUTER_VALUES,
position=(800, 200),
)
out_ok = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-ok",
position=(1200, 60),
)
out_sonder = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-sonder",
position=(1200, 340),
)
nodes = [entry, rule, router, out_ok, out_sonder]
edges = [
edge(entry, "message", rule, "payload"),
edge(rule, "verdict", router, "input_text"),
edge(router, "false_result", out_ok, "input_value"),
edge(router, "true_result", out_sonder, "input_value"),
]
return flow_doc(
"simple_rule",
"Regelpruefung ohne Seiteneffekt (Spike-Fixture, kein LLM).",
nodes,
edges,
)
def build_approval_handoff() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 300),
)
envelope = custom_node(
ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300)
)
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",
"ConditionalRouterComponent",
"ConditionalRouter-router",
values=ROUTER_VALUES,
position=(1200, 300),
)
callback = custom_node(
APPROVAL_CALLBACK_CODE,
"CustomComponent-callback",
position=(1600, 160),
)
out_pending = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-pending",
position=(2000, 160),
)
out_ok = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-ok",
position=(2000, 440),
)
nodes = [entry, envelope, rule, router, callback, out_pending, out_ok]
edges = [
edge(entry, "message", envelope, "raw"),
edge(envelope, "payload", rule, "payload"),
edge(rule, "verdict", router, "input_text"),
edge(router, "true_result", callback, "trigger"),
edge(envelope, "run_id", callback, "run_id"),
edge(envelope, "callback_url", callback, "callback_url"),
edge(envelope, "continuation", callback, "continuation_flow_id"),
edge(envelope, "payload", callback, "facts"),
edge(callback, "result", out_pending, "input_value"),
edge(router, "false_result", out_ok, "input_value"),
]
return flow_doc(
"approval_handoff",
"Sonderfall meldet sich per HTTP an unsere API und terminiert (Spike).",
nodes,
edges,
)
def main() -> None:
FIXTURE_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}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -47,15 +47,27 @@ def db_engine():
@pytest.fixture()
def session(db_engine):
"""Function-scoped session; tables are truncated after each test."""
from sqlmodel import Session, SQLModel
"""Function-scoped session for direct DB access in tests."""
from sqlmodel import Session
with Session(db_engine) as s:
yield s
@pytest.fixture(autouse=True)
def _clean_tables(db_engine):
"""Truncate all domain tables before and after every test (isolation)."""
from sqlmodel import SQLModel
def _truncate() -> None:
with db_engine.begin() as conn:
for table in reversed(SQLModel.metadata.sorted_tables):
conn.execute(table.delete())
_truncate()
yield
_truncate()
@pytest.fixture()
async def client():
@ -67,3 +79,113 @@ async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture(scope="session")
def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it.
Serves an (initially seeded) flow dir; tests upload fixture flows through
the same deploy path production uses.
"""
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from app.settings import settings
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
).read_text()
(flow_dir / "seed.json").write_text(seed)
env = {
**os.environ,
# lfx serve refuses to start without a serve key; requests need it too.
"LFX_API_KEY": "test-lfx-key",
}
proc = subprocess.Popen(
[
lfx_bin,
"serve",
"--host",
"127.0.0.1",
"--port",
str(port),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
url = f"http://127.0.0.1:{port}"
deadline = time.time() + 240
healthy = False
while time.time() < deadline:
if proc.poll() is not None:
out = proc.stdout.read() if proc.stdout else ""
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}")
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
if response.status == 200:
healthy = True
break
except OSError:
time.sleep(1)
if not healthy:
proc.terminate()
raise RuntimeError("lfx serve did not become healthy in time")
old_url = settings.lfx_url
old_key = settings.lfx_api_key
settings.lfx_url = url
settings.lfx_api_key = "test-lfx-key"
yield url
settings.lfx_url = old_url
settings.lfx_api_key = old_key
proc.terminate()
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="session")
def live_server():
"""Real uvicorn server (ephemeral port) so LFX flows can call back
into our API over HTTP, exactly like production."""
import threading
import time
import uvicorn
from app.main import app
config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 30
while not server.started and time.time() < deadline:
time.sleep(0.05)
assert server.started, "uvicorn test server did not start"
port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=15)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,248 @@
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
import json
from pathlib import Path
from uuid import uuid4
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
async def _create_flow(client, name="regeltest"):
response = await client.post(
"/api/flows", json={"name": name, "description": "test flow"}
)
assert response.status_code == 201, response.text
return response.json()
async def _create_version(client, flow_id, fixture="simple_rule.json"):
response = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": load_fixture(fixture), "author": "test"},
)
assert response.status_code == 201, response.text
return response.json()
async def _deploy(client, flow_id, version_no=1):
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
assert response.status_code == 200, response.text
return response.json()
async def test_flow_crud(client):
created = await _create_flow(client, "crud-flow")
assert created["status"] == "active"
response = await client.get(f"/api/flows/{created['id']}")
assert response.status_code == 200
assert response.json()["name"] == "crud-flow"
response = await client.get("/api/flows")
assert response.status_code == 200
assert any(f["id"] == created["id"] for f in response.json())
response = await client.post("/api/flows", json={"name": "crud-flow"})
assert response.status_code == 409
response = await client.get(f"/api/flows/{uuid4()}")
assert response.status_code == 404
async def test_version_validation_and_numbering(client):
flow = await _create_flow(client, "versioned")
v1 = await _create_version(client, flow["id"])
assert v1["version_no"] == 1
v2 = await _create_version(client, flow["id"])
assert v2["version_no"] == 2
assert v1["deployed_at"] is None
response = await client.post(
f"/api/flows/{flow['id']}/versions", json={"langflow_json": {"nope": 1}}
)
assert response.status_code == 422
response = await client.post(
f"/api/flows/{uuid4()}/versions",
json={"langflow_json": load_fixture("simple_rule.json")},
)
assert response.status_code == 404
response = await client.get(f"/api/flows/{flow['id']}/versions")
assert [v["version_no"] for v in response.json()] == [1, 2]
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["deployed_at"] is not None
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 120000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
run = response.json()
assert run["status"] == "completed"
assert "Standardfall" in run["output"]["result"]
assert run["finished_at"] is not None
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
run = response.json()
assert run["status"] == "completed"
assert "Sonderfall" in run["output"]["result"]
response = await client.get(f"/api/flows/runs/{run['id']}")
assert response.status_code == 200
assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client):
flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
assert response.status_code == 422
async def test_run_explicit_version_no(client, lfx_server):
flow = await _create_flow(client, "explicit-version")
await _create_version(client, flow["id"])
await _create_version(client, flow["id"])
await _deploy(client, flow["id"], version_no=2)
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "version_no": 2},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server):
flow = await _create_flow(client, "streamed")
await _create_version(client, flow["id"])
await _deploy(client, flow["id"])
events: list[tuple[str, str]] = []
async with client.stream(
"POST",
f"/api/flows/{flow['id']}/run?stream=true",
json={"input": {"betrag": 1}},
) as response:
assert response.status_code == 200, await response.aread()
current_event = "message"
async for line in response.aiter_lines():
if line.startswith("event:"):
current_event = line.split(":", 1)[1].strip()
elif line.startswith("data:"):
events.append((current_event, line.split(":", 1)[1].strip()))
current_event = "message"
assert events, "expected SSE events"
assert events[0][0] == "run"
first_payload = json.loads(events[0][1])
assert "run_id" in first_payload
assert events[-1][0] == "run_finished"
last_payload = json.loads(events[-1][1])
assert last_payload["status"] == "completed"
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
assert response.status_code == 200
assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
flow = await _create_flow(client, "freigabe")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
},
)
assert response.status_code == 200, response.text
run = response.json()
assert run["status"] == "pending_approval"
assert "PENDING_HUMAN_APPROVAL" in run["output"]["result"]
response = await client.get("/api/approvals")
assert response.status_code == 200
tasks = response.json()
assert len(tasks) == 1
task = tasks[0]
assert task["run_id"] == run["id"]
assert task["status"] == "open"
assert task["payload"]["facts"]["betrag"] == 250000
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
flow = await _create_flow(client, "freigabe-auto")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 50000, "schwelle": 200000},
"callback_base_url": live_server,
},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
response = await client.get("/api/approvals")
assert response.status_code == 200
assert response.json() == []
async def test_approval_callback_contract(client, session):
from uuid import UUID
from app.flows import store as flow_store
flow = await _create_flow(client, "callback-target")
version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post(
"/api/approvals",
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
)
assert response.status_code == 201, response.text
task = response.json()
assert task["status"] == "open"
assert task["tenant_id"] == "default"
assert task["version_id"] == version["id"]
response = await client.get(f"/api/flows/runs/{run.id}")
assert response.json()["status"] == "pending_approval"
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
assert response.status_code == 409
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post(
"/api/approvals",
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
)
assert response.status_code == 422
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
assert response.status_code == 404