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
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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue