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>
269 lines
7 KiB
Python
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)
|