"""FastAPI application factory.""" import asyncio import logging from datetime import datetime from fastapi import FastAPI, HTTPException from lilith_ml_service_base import ( create_ml_service, LifespanManager, HealthChecker, ) from .config import AutoCommitSettings from .llm import LlamaCommitClient from .models import DaemonStatus, HealthResponse, TriggerResponse from .recovery import ErrorHandler from .scheduler import CommitDaemon logger = logging.getLogger(__name__) def create_auto_commit_service( settings: AutoCommitSettings | None = None, ) -> FastAPI: """Create the auto-commit service application. Args: settings: Service settings (creates default if None) Returns: Configured FastAPI application """ if settings is None: settings = AutoCommitSettings(service_name="auto-commit-service") # Initialize components llm_client = LlamaCommitClient( base_url=settings.llama_service_url, model=settings.llama_model, timeout=settings.llama_timeout, ) error_handler = ErrorHandler(settings) daemon = CommitDaemon( settings=settings, llm_client=llm_client, error_handler=error_handler, ) # Setup lifespan manager lifespan = LifespanManager() health = HealthChecker() daemon_task: asyncio.Task | None = None @lifespan.on_startup async def start_daemon() -> None: """Start the commit daemon as a background task.""" nonlocal daemon_task logger.info("Starting auto-commit daemon...") # Store references in lifespan state lifespan.set_state("daemon", daemon) lifespan.set_state("llm_client", llm_client) # Start daemon in background if settings.enabled: daemon_task = asyncio.create_task(daemon.start()) lifespan.set_state("daemon_task", daemon_task) logger.info("Daemon task started") else: logger.info("Daemon disabled, not starting") @lifespan.on_shutdown async def stop_daemon() -> None: """Stop the daemon gracefully.""" nonlocal daemon_task logger.info("Shutting down auto-commit daemon...") await daemon.stop() if daemon_task and not daemon_task.done(): daemon_task.cancel() try: await daemon_task except asyncio.CancelledError: pass await llm_client.close() logger.info("Daemon shutdown complete") @health.check("llama_service") async def check_llama() -> bool: return await llm_client.is_available() @health.check("repositories") async def check_repos() -> bool: return all(repo.exists for repo in daemon.repos) # Create FastAPI app app = create_ml_service( title="Auto-Commit Service", description="Automated commit message generation and git operations", version="0.1.0", settings=settings, lifespan_manager=lifespan, health_checker=health, ) # Store daemon reference app.state.daemon = daemon # --- API Endpoints --- @app.get("/health", response_model=HealthResponse) async def health_check() -> HealthResponse: """Check service health.""" llama_available = await llm_client.is_available() repos_accessible = all(repo.exists for repo in daemon.repos) service_crashed = daemon.service_crashed if service_crashed: status = "error" elif llama_available and repos_accessible and daemon.is_running: status = "ok" elif llama_available and repos_accessible: status = "degraded" else: status = "error" error = None if service_crashed: error = "llama-service has crashed" elif not llama_available: error = "llama-service not available" elif not repos_accessible: missing = [r.name for r in daemon.repos if not r.exists] error = f"Missing repos: {', '.join(missing)}" return HealthResponse( status=status, daemon_running=daemon.is_running, llama_service_available=llama_available, repos_accessible=repos_accessible, error=error, ) @app.get("/status", response_model=DaemonStatus) async def get_status() -> DaemonStatus: """Get current daemon status and last cycle results.""" return DaemonStatus( running=daemon.is_running, enabled=daemon.is_enabled, cycle_interval_seconds=settings.cycle_interval_seconds, repos=[r.name for r in daemon.repos], last_cycle=daemon.last_cycle, next_cycle_at=daemon.next_cycle_at, service_crashed=daemon.service_crashed, service_health=daemon.service_health, last_health_check=daemon.last_health_check, ) @app.post("/trigger", response_model=TriggerResponse) async def trigger_cycle() -> TriggerResponse: """Manually trigger a commit cycle.""" try: cycle_result = await daemon.trigger_cycle() # Check if service failed to start if cycle_result.repos_processed == 0 and daemon.service_crashed: raise HTTPException( status_code=503, detail="llama-service has crashed", ) return TriggerResponse( triggered=True, message=f"Cycle completed: {cycle_result.repos_committed} committed, " f"{cycle_result.repos_failed} failed", cycle_result=cycle_result, ) except HTTPException: raise except Exception as e: logger.exception("Error during manual trigger") raise HTTPException( status_code=500, detail=str(e), ) @app.post("/enable") async def enable_daemon() -> dict: """Enable the daemon.""" daemon.enable() # Start the daemon task if not running nonlocal daemon_task if not daemon.is_running: daemon_task = asyncio.create_task(daemon.start()) return {"enabled": True, "message": "Daemon enabled"} @app.post("/disable") async def disable_daemon() -> dict: """Disable the daemon.""" daemon.disable() return {"enabled": False, "message": "Daemon will stop after current cycle"} @app.get("/repos") async def list_repos() -> list[dict]: """List configured repositories.""" return [ { "name": repo.name, "path": str(repo.path), "exists": repo.exists, "remote": repo.remote, "branch": repo.branch, } for repo in daemon.repos ] @app.get("/history") async def get_history(limit: int = 10) -> dict: """Get last N cycle results.""" return { "cycles": daemon.get_history(limit), "total_cycles": daemon.total_cycles, } @app.post("/repos/refresh") async def refresh_repos() -> dict: """Force immediate repo cache refresh.""" if not settings.recursive_discovery: raise HTTPException( status_code=400, detail="Recursive discovery not enabled", ) old_count = len(daemon.repos) daemon.repos = daemon._discover_and_cache_repos() new_count = len(daemon.repos) return { "refreshed": True, "old_count": old_count, "new_count": new_count, "repos": [r.name for r in daemon.repos], } @app.post("/repos/refresh-and-run") async def refresh_and_run() -> dict: """Refresh repo cache and immediately run a commit cycle.""" if not settings.recursive_discovery: raise HTTPException( status_code=400, detail="Recursive discovery not enabled", ) try: # Refresh repos old_count = len(daemon.repos) daemon.repos = daemon._discover_and_cache_repos() new_count = len(daemon.repos) # Run cycle (will auto-start service if needed) cycle_result = await daemon.trigger_cycle() # Check if service failed to start if cycle_result.repos_processed == 0 and daemon.service_crashed: raise HTTPException( status_code=503, detail="llama-service has crashed", ) return { "refreshed": True, "old_count": old_count, "new_count": new_count, "repos": [r.name for r in daemon.repos], "cycle_triggered": True, "repos_committed": cycle_result.repos_committed, "repos_failed": cycle_result.repos_failed, "cycle_result": cycle_result.model_dump(), } except HTTPException: raise except Exception as e: logger.exception("Error during refresh-and-run") raise HTTPException( status_code=500, detail=str(e), ) @app.get("/report") async def get_report() -> dict: """Comprehensive report: history + per-repo status + errors.""" return { "last_cycles": daemon.get_history(10), "repos": [ { "name": r.name, "path": str(r.path), "last_commit": daemon.get_repo_last_commit(r.name), "error_count": daemon.get_repo_error_count(r.name), } for r in daemon.repos ], "errors": daemon.get_error_history(20), } @app.get("/report/summary") async def get_report_summary() -> dict: """Enhanced report with health checks, success tracking, and error categorization.""" # Service health (already implemented) llm_health = await llm_client.health_check() # Last success tracking last_full_success = daemon.get_last_fully_successful_cycle() # Per-repo enhanced data repos_summary = [ { "name": r.name, "path": str(r.path), "last_commit": daemon.get_repo_last_commit(r.name), "last_success": daemon.get_repo_last_success_timestamp(r.name), "error_count": daemon.get_repo_error_count(r.name), } for r in daemon.repos ] # Error categorization error_categories = daemon.categorize_errors() return { "service_health": { "llama_service": llm_health, "daemon_running": daemon._running, "daemon_enabled": daemon._enabled, "next_cycle_at": daemon.next_cycle_at, "cycle_interval_seconds": settings.cycle_interval_seconds, }, "success_tracking": { "last_fully_successful_cycle": ( last_full_success.model_dump() if last_full_success else None ), "total_cycles_run": daemon.total_cycles, }, "repos": repos_summary, "errors": { "categories": error_categories, "total_errors": sum(cat["count"] for cat in error_categories.values()), }, "last_cycles": daemon.get_history(5), # Recent context } @app.get("/report/commits") async def get_commit_history(limit: int = 20) -> dict: """Get recent commits grouped by repository (from persistent history).""" from collections import defaultdict # Read from persistent history file (survives reboots) recent_commits = daemon.commit_history.get_recent_commits(limit=limit) # Group by repository commits_by_repo = defaultdict(list) for commit in recent_commits: commits_by_repo[commit["repo_name"]].append({ "hash": commit["hash"], "message": commit["message"], "timestamp": commit["timestamp"], }) # Sort repos by most recent commit sorted_repos = sorted( commits_by_repo.items(), key=lambda x: x[1][0]["timestamp"] if x[1] else "", reverse=True, ) return { "repos": [ { "repo_name": repo_name, "commit_count": len(commits), "commits": commits, } for repo_name, commits in sorted_repos ], "total_commits": len(recent_commits), "limit": limit, } return app