xml-pipeline/bloxserver/api/routes/flows.py
dullfig a5c00c1e90 Add BloxServer API scaffold + architecture docs
BloxServer API (FastAPI + SQLAlchemy async):
- Database models: users, flows, triggers, executions, usage tracking
- Clerk JWT auth with dev mode bypass for local testing
- SQLite support for local dev, PostgreSQL for production
- CRUD routes for flows, triggers, executions
- Public webhook endpoint with token auth
- Health/readiness endpoints
- Pydantic schemas with camelCase aliases for frontend
- Docker + docker-compose setup

Architecture documentation:
- Librarian architecture with RLM-powered query engine
- Stripe billing integration (usage-based, trials, webhooks)
- LLM abstraction layer (rate limiting, semantic cache, failover)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:04:25 -08:00

269 lines
7 KiB
Python

"""
Flow CRUD endpoints.
Flows are the core entity - a user's workflow definition.
"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from bloxserver.api.dependencies import AuthenticatedUser, DbSession
from bloxserver.api.models.tables import FlowRecord, Tier
from bloxserver.api.schemas import (
CreateFlowRequest,
Flow,
FlowSummary,
PaginatedResponse,
UpdateFlowRequest,
)
router = APIRouter(prefix="/flows", tags=["flows"])
# Default organism.yaml template for new flows
DEFAULT_ORGANISM_YAML = """organism:
name: my-flow
listeners:
- name: greeter
payload_class: handlers.hello.Greeting
handler: handlers.hello.handle_greeting
description: A friendly greeter agent
agent: true
peers: []
"""
# Tier limits
TIER_FLOW_LIMITS = {
Tier.FREE: 1,
Tier.PRO: 100, # Effectively unlimited for most users
Tier.ENTERPRISE: 1000,
Tier.HIGH_FREQUENCY: 1000,
}
@router.get("", response_model=PaginatedResponse[FlowSummary])
async def list_flows(
user: AuthenticatedUser,
db: DbSession,
page: int = 1,
page_size: int = 20,
) -> PaginatedResponse[FlowSummary]:
"""List all flows for the current user."""
offset = (page - 1) * page_size
# Get total count
count_query = select(func.count()).select_from(FlowRecord).where(
FlowRecord.user_id == user.id
)
total = (await db.execute(count_query)).scalar() or 0
# Get page of flows
query = (
select(FlowRecord)
.where(FlowRecord.user_id == user.id)
.order_by(FlowRecord.updated_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(query)
flows = result.scalars().all()
return PaginatedResponse(
items=[FlowSummary.model_validate(f) for f in flows],
total=total,
page=page,
page_size=page_size,
has_more=offset + len(flows) < total,
)
@router.post("", response_model=Flow, status_code=status.HTTP_201_CREATED)
async def create_flow(
user: AuthenticatedUser,
db: DbSession,
request: CreateFlowRequest,
) -> Flow:
"""Create a new flow."""
# Check tier limits
count_query = select(func.count()).select_from(FlowRecord).where(
FlowRecord.user_id == user.id
)
current_count = (await db.execute(count_query)).scalar() or 0
limit = TIER_FLOW_LIMITS.get(user.user.tier, 1)
if current_count >= limit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Flow limit reached ({limit}). Upgrade to create more flows.",
)
# Create flow
flow = FlowRecord(
user_id=user.id,
name=request.name,
description=request.description,
organism_yaml=request.organism_yaml or DEFAULT_ORGANISM_YAML,
)
db.add(flow)
await db.flush()
return Flow.model_validate(flow)
@router.get("/{flow_id}", response_model=Flow)
async def get_flow(
flow_id: UUID,
user: AuthenticatedUser,
db: DbSession,
) -> Flow:
"""Get a single flow by ID."""
query = select(FlowRecord).where(
FlowRecord.id == flow_id,
FlowRecord.user_id == user.id,
)
result = await db.execute(query)
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow not found",
)
return Flow.model_validate(flow)
@router.patch("/{flow_id}", response_model=Flow)
async def update_flow(
flow_id: UUID,
user: AuthenticatedUser,
db: DbSession,
request: UpdateFlowRequest,
) -> Flow:
"""Update a flow."""
query = select(FlowRecord).where(
FlowRecord.id == flow_id,
FlowRecord.user_id == user.id,
)
result = await db.execute(query)
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow not found",
)
# Update fields that were provided
if request.name is not None:
flow.name = request.name
if request.description is not None:
flow.description = request.description
if request.organism_yaml is not None:
flow.organism_yaml = request.organism_yaml
if request.canvas_state is not None:
flow.canvas_state = request.canvas_state.model_dump()
await db.flush()
return Flow.model_validate(flow)
@router.delete("/{flow_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_flow(
flow_id: UUID,
user: AuthenticatedUser,
db: DbSession,
) -> None:
"""Delete a flow."""
query = select(FlowRecord).where(
FlowRecord.id == flow_id,
FlowRecord.user_id == user.id,
)
result = await db.execute(query)
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow not found",
)
await db.delete(flow)
# =============================================================================
# Flow Actions (Start/Stop)
# =============================================================================
@router.post("/{flow_id}/start", response_model=Flow)
async def start_flow(
flow_id: UUID,
user: AuthenticatedUser,
db: DbSession,
) -> Flow:
"""Start a flow (deploy container)."""
query = select(FlowRecord).where(
FlowRecord.id == flow_id,
FlowRecord.user_id == user.id,
)
result = await db.execute(query)
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow not found",
)
if flow.status not in ("stopped", "error"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot start flow in {flow.status} state",
)
# TODO: Actually start the container
# This is where we'd call the container orchestration layer
# For now, just update the status
flow.status = "starting"
flow.error_message = None
await db.flush()
return Flow.model_validate(flow)
@router.post("/{flow_id}/stop", response_model=Flow)
async def stop_flow(
flow_id: UUID,
user: AuthenticatedUser,
db: DbSession,
) -> Flow:
"""Stop a running flow."""
query = select(FlowRecord).where(
FlowRecord.id == flow_id,
FlowRecord.user_id == user.id,
)
result = await db.execute(query)
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow not found",
)
if flow.status not in ("running", "starting", "error"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot stop flow in {flow.status} state",
)
# TODO: Actually stop the container
flow.status = "stopping"
await db.flush()
return Flow.model_validate(flow)