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