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:
parent
e178ae406d
commit
96c0da33c7
15 changed files with 5070 additions and 5 deletions
|
|
@ -68,6 +68,8 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.lfx
|
dockerfile: Dockerfile.lfx
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
volumes:
|
volumes:
|
||||||
- lfx-data:/flows
|
- lfx-data:/flows
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ LANGFLOW_DATABASE_URL=postgresql://kirche:<password>@db:5432/langflow
|
||||||
|
|
||||||
# --- lfx (flow runtime) ---
|
# --- lfx (flow runtime) ---
|
||||||
LFX_URL=http://lfx:6004
|
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 (optional; tests mock the provider without it) ---
|
||||||
# LLM_API_KEY=
|
# LLM_API_KEY=
|
||||||
|
|
|
||||||
0
backend/app/flows/__init__.py
Normal file
0
backend/app/flows/__init__.py
Normal file
311
backend/app/flows/api.py
Normal file
311
backend/app/flows/api.py
Normal 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)]
|
||||||
184
backend/app/flows/lfx_client.py
Normal file
184
backend/app/flows/lfx_client.py
Normal 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}")
|
||||||
93
backend/app/flows/runner.py
Normal file
93
backend/app/flows/runner.py
Normal 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)
|
||||||
86
backend/app/flows/schemas.py
Normal file
86
backend/app/flows/schemas.py
Normal 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
225
backend/app/flows/store.py
Normal 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())
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from .flows.api import approvals_router, flows_router
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
@ -11,6 +12,9 @@ app = FastAPI(
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(flows_router)
|
||||||
|
app.include_router(approvals_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ class Settings(BaseSettings):
|
||||||
langflow_url: str = "http://localhost:7860"
|
langflow_url: str = "http://localhost:7860"
|
||||||
langflow_api_key: str = ""
|
langflow_api_key: str = ""
|
||||||
lfx_url: str = "http://localhost:6004"
|
lfx_url: str = "http://localhost:6004"
|
||||||
|
lfx_api_key: str = ""
|
||||||
llm_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()
|
settings = Settings()
|
||||||
|
|
|
||||||
444
backend/scripts/make_flow_fixtures.py
Normal file
444
backend/scripts/make_flow_fixtures.py
Normal 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())
|
||||||
|
|
@ -47,14 +47,26 @@ def db_engine():
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def session(db_engine):
|
def session(db_engine):
|
||||||
"""Function-scoped session; tables are truncated after each test."""
|
"""Function-scoped session for direct DB access in tests."""
|
||||||
from sqlmodel import Session, SQLModel
|
from sqlmodel import Session
|
||||||
|
|
||||||
with Session(db_engine) as s:
|
with Session(db_engine) as s:
|
||||||
yield s
|
yield s
|
||||||
with db_engine.begin() as conn:
|
|
||||||
for table in reversed(SQLModel.metadata.sorted_tables):
|
|
||||||
conn.execute(table.delete())
|
@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()
|
@pytest.fixture()
|
||||||
|
|
@ -67,3 +79,113 @@ async def client():
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
yield 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)
|
||||||
|
|
|
||||||
1945
backend/tests/fixtures/flows/approval_handoff.json
vendored
Normal file
1945
backend/tests/fixtures/flows/approval_handoff.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1393
backend/tests/fixtures/flows/simple_rule.json
vendored
Normal file
1393
backend/tests/fixtures/flows/simple_rule.json
vendored
Normal file
File diff suppressed because one or more lines are too long
248
backend/tests/test_flows_api.py
Normal file
248
backend/tests/test_flows_api.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue