feat: flow runtime on LFX with tests

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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