Add multi-user automation features and per-user error tracking. - Database migrations: add workflow_configs/workflow_runs (004), app_errors (005), pipeline_configs/pipeline_runs (006), and add user_token_hash to app_errors (007). - Backend: introduce per-request token handling (X-API-Token) via app.api.deps and update many API routes (auth, automations, bank, characters, dashboard, events, exchange, logs) to use user-scoped Artifacts client and character scoping. Auth endpoints no longer store tokens server-side (validate-only); clear is a no-op on server. - New Errors API and services: endpoint to list, filter, resolve, and report errors scoped to the requesting user; add error models, schemas, middleware/error handler and error_service for recording/hashing tokens. - Pipelines & Workflows: add API routers, models, schemas and engine modules (pipeline/worker/coordinator, workflow runner/conditions) and action_executor updates to support workflow/pipeline execution. - Logs: logs endpoint now prefers fetching recent action logs from the game API (with fallback to local DB), supports paging and filtering, and scopes results to the user. - Frontend: add pipeline/workflow builders, lists, progress components and hooks (use-errors, use-pipelines, use-workflows), sentry client config, and updates to API client/constants/types. - Misc: add middleware error handler, various engine strategy tweaks, tests adjusted. Overall this change enables per-user API tokens, scopes DB queries to each user, introduces pipelines/workflows runtime support, and centralizes application error tracking.
267 lines
9.7 KiB
Python
267 lines
9.7 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.api.deps import get_user_character_names
|
|
from app.database import async_session_factory
|
|
from app.engine.manager import AutomationManager
|
|
from app.models.automation import AutomationLog
|
|
from app.models.pipeline import PipelineConfig, PipelineRun
|
|
from app.schemas.automation import AutomationLogResponse
|
|
from app.schemas.pipeline import (
|
|
PipelineConfigCreate,
|
|
PipelineConfigDetailResponse,
|
|
PipelineConfigResponse,
|
|
PipelineConfigUpdate,
|
|
PipelineRunResponse,
|
|
PipelineStatusResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/pipelines", tags=["pipelines"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_manager(request: Request) -> AutomationManager:
|
|
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
|
|
if manager is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Automation engine is not available",
|
|
)
|
|
return manager
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRUD -- Pipeline Configs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _pipeline_belongs_to_user(pipeline: PipelineConfig, user_chars: set[str]) -> bool:
|
|
"""Check if any character in the pipeline stages belongs to the user."""
|
|
for stage in (pipeline.stages or []):
|
|
for step in (stage.get("character_steps") or []):
|
|
if step.get("character_name") in user_chars:
|
|
return True
|
|
return False
|
|
|
|
|
|
@router.get("/", response_model=list[PipelineConfigResponse])
|
|
async def list_pipelines(request: Request) -> list[PipelineConfigResponse]:
|
|
"""List pipeline configurations belonging to the current user."""
|
|
user_chars = set(await get_user_character_names(request))
|
|
async with async_session_factory() as db:
|
|
stmt = select(PipelineConfig).order_by(PipelineConfig.id)
|
|
result = await db.execute(stmt)
|
|
configs = result.scalars().all()
|
|
return [
|
|
PipelineConfigResponse.model_validate(c)
|
|
for c in configs
|
|
if _pipeline_belongs_to_user(c, user_chars)
|
|
]
|
|
|
|
|
|
@router.post("/", response_model=PipelineConfigResponse, status_code=201)
|
|
async def create_pipeline(
|
|
payload: PipelineConfigCreate,
|
|
request: Request,
|
|
) -> PipelineConfigResponse:
|
|
"""Create a new pipeline configuration."""
|
|
async with async_session_factory() as db:
|
|
config = PipelineConfig(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
stages=[stage.model_dump() for stage in payload.stages],
|
|
loop=payload.loop,
|
|
max_loops=payload.max_loops,
|
|
)
|
|
db.add(config)
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
return PipelineConfigResponse.model_validate(config)
|
|
|
|
|
|
@router.get("/status/all", response_model=list[PipelineStatusResponse])
|
|
async def get_all_pipeline_statuses(request: Request) -> list[PipelineStatusResponse]:
|
|
"""Get live status for all active pipelines."""
|
|
manager = _get_manager(request)
|
|
return manager.get_all_pipeline_statuses()
|
|
|
|
|
|
@router.get("/{pipeline_id}", response_model=PipelineConfigDetailResponse)
|
|
async def get_pipeline(pipeline_id: int, request: Request) -> PipelineConfigDetailResponse:
|
|
"""Get a pipeline configuration with its run history."""
|
|
async with async_session_factory() as db:
|
|
stmt = (
|
|
select(PipelineConfig)
|
|
.options(selectinload(PipelineConfig.runs))
|
|
.where(PipelineConfig.id == pipeline_id)
|
|
)
|
|
result = await db.execute(stmt)
|
|
config = result.scalar_one_or_none()
|
|
|
|
if config is None:
|
|
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
|
|
return PipelineConfigDetailResponse(
|
|
config=PipelineConfigResponse.model_validate(config),
|
|
runs=[PipelineRunResponse.model_validate(r) for r in config.runs],
|
|
)
|
|
|
|
|
|
@router.put("/{pipeline_id}", response_model=PipelineConfigResponse)
|
|
async def update_pipeline(
|
|
pipeline_id: int,
|
|
payload: PipelineConfigUpdate,
|
|
request: Request,
|
|
) -> PipelineConfigResponse:
|
|
"""Update a pipeline configuration. Cannot update while running."""
|
|
manager = _get_manager(request)
|
|
if manager.is_pipeline_running(pipeline_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot update a pipeline while it is running. Stop it first.",
|
|
)
|
|
|
|
async with async_session_factory() as db:
|
|
config = await db.get(PipelineConfig, pipeline_id)
|
|
if config is None:
|
|
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
|
|
if payload.name is not None:
|
|
config.name = payload.name
|
|
if payload.description is not None:
|
|
config.description = payload.description
|
|
if payload.stages is not None:
|
|
config.stages = [stage.model_dump() for stage in payload.stages]
|
|
if payload.loop is not None:
|
|
config.loop = payload.loop
|
|
if payload.max_loops is not None:
|
|
config.max_loops = payload.max_loops
|
|
if payload.enabled is not None:
|
|
config.enabled = payload.enabled
|
|
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
return PipelineConfigResponse.model_validate(config)
|
|
|
|
|
|
@router.delete("/{pipeline_id}", status_code=204)
|
|
async def delete_pipeline(pipeline_id: int, request: Request) -> None:
|
|
"""Delete a pipeline configuration. Cannot delete while running."""
|
|
manager = _get_manager(request)
|
|
if manager.is_pipeline_running(pipeline_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot delete a pipeline while it is running. Stop it first.",
|
|
)
|
|
|
|
async with async_session_factory() as db:
|
|
config = await db.get(PipelineConfig, pipeline_id)
|
|
if config is None:
|
|
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
await db.delete(config)
|
|
await db.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Control -- Start / Stop / Pause / Resume
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{pipeline_id}/start", response_model=PipelineRunResponse)
|
|
async def start_pipeline(pipeline_id: int, request: Request) -> PipelineRunResponse:
|
|
"""Start a pipeline from its configuration."""
|
|
manager = _get_manager(request)
|
|
try:
|
|
return await manager.start_pipeline(pipeline_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{pipeline_id}/stop", status_code=204)
|
|
async def stop_pipeline(pipeline_id: int, request: Request) -> None:
|
|
"""Stop a running pipeline."""
|
|
manager = _get_manager(request)
|
|
try:
|
|
await manager.stop_pipeline(pipeline_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{pipeline_id}/pause", status_code=204)
|
|
async def pause_pipeline(pipeline_id: int, request: Request) -> None:
|
|
"""Pause a running pipeline."""
|
|
manager = _get_manager(request)
|
|
try:
|
|
await manager.pause_pipeline(pipeline_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{pipeline_id}/resume", status_code=204)
|
|
async def resume_pipeline(pipeline_id: int, request: Request) -> None:
|
|
"""Resume a paused pipeline."""
|
|
manager = _get_manager(request)
|
|
try:
|
|
await manager.resume_pipeline(pipeline_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status & Logs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{pipeline_id}/status", response_model=PipelineStatusResponse)
|
|
async def get_pipeline_status(
|
|
pipeline_id: int,
|
|
request: Request,
|
|
) -> PipelineStatusResponse:
|
|
"""Get live status for a specific pipeline."""
|
|
manager = _get_manager(request)
|
|
status = manager.get_pipeline_status(pipeline_id)
|
|
if status is None:
|
|
async with async_session_factory() as db:
|
|
config = await db.get(PipelineConfig, pipeline_id)
|
|
if config is None:
|
|
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
return PipelineStatusResponse(
|
|
pipeline_id=pipeline_id,
|
|
status="stopped",
|
|
total_stages=len(config.stages),
|
|
)
|
|
return status
|
|
|
|
|
|
@router.get("/{pipeline_id}/logs", response_model=list[AutomationLogResponse])
|
|
async def get_pipeline_logs(
|
|
pipeline_id: int,
|
|
request: Request,
|
|
limit: int = 100,
|
|
) -> list[AutomationLogResponse]:
|
|
"""Get recent logs for a pipeline (across all its runs)."""
|
|
async with async_session_factory() as db:
|
|
config = await db.get(PipelineConfig, pipeline_id)
|
|
if config is None:
|
|
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
|
|
stmt = (
|
|
select(AutomationLog)
|
|
.join(PipelineRun, AutomationLog.run_id == PipelineRun.id)
|
|
.where(PipelineRun.pipeline_id == pipeline_id)
|
|
.order_by(AutomationLog.created_at.desc())
|
|
.limit(min(limit, 500))
|
|
)
|
|
result = await db.execute(stmt)
|
|
logs = result.scalars().all()
|
|
return [AutomationLogResponse.model_validate(log) for log in logs]
|