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:
|
||||
context: .
|
||||
dockerfile: Dockerfile.lfx
|
||||
env_file:
|
||||
- ../.env
|
||||
volumes:
|
||||
- lfx-data:/flows
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
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 .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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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,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)
|
||||
|
|
|
|||
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