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

225
backend/app/flows/store.py Normal file
View 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())