Files
ai-agent/backend/main.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

161 lines
5.7 KiB
Python

"""
ai-agent backend — a viewer/editor + analytics dashboard for the homelab's
Claude context.
Exposes the repo's CLAUDE.md files and the `.claude/` tree (skills, hooks,
settings) as a flat list of files with **real** Claude token counts and dollar
costs, lets them be edited, and surfaces skill-usage analytics mined from the
Claude Code transcripts.
Token counts and skill stats are maintained in a small SQLite store by a
background indexer (see indexer.py): token counts come from the count_tokens
endpoint and are only refreshed for files whose content changed, after a debounce.
The built React PWA is served from STATIC_DIR at the web root.
This module is the assembly only: the routes live in one package per domain
(``conversations/``, ``runs/``, ``notifications/``, …), each exposing an
``APIRouter``; the shared stores are built once by ``core.state.build_state``
and attached to ``app.state.ai``, which every route reads through the
``State`` dependency. See ``CLAUDE.md`` → "Backend modules".
"""
import pathlib
import threading
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
import accounts.routes
import agents.routes
import conversations.routes
import core.auth
import core.routes
import cron.routes
import dashboards.routes
import diff.routes
import files.assets
import files.avatar
import files.routes
import forms.routes
import goals.routes
import memories.routes
import models.routes
import notifications.routes
import plans.routes
import projects.routes
import runs.routes
import services.routes
import settings.routes
import templates.routes
import workers.routes
from core.config import STATIC_DIR
from core.state import AppState, build_state
from models import openrouter as openrouter_mod
from runs.sidecar_client import _watch_sidecar_runs
# Declaration order matters only for the SPA fallback (last) — every API path
# is distinct. Kept in the order the old monolith declared them.
ROUTERS = (
files.routes.router,
settings.routes.router,
dashboards.routes.router,
conversations.routes.router,
notifications.routes.router,
diff.routes.router,
accounts.routes.router,
workers.routes.router,
models.routes.router,
runs.routes.router,
cron.routes.router,
agents.routes.router,
forms.routes.router,
projects.routes.router,
files.assets.router,
files.avatar.router,
services.routes.router,
goals.routes.router,
memories.routes.router,
plans.routes.router,
templates.routes.router,
core.routes.router,
)
def create_app() -> FastAPI:
app = FastAPI(title="ai-agent", docs_url=None, redoc_url=None)
core.auth.install(app)
app.state.ai = build_state()
for r in ROUTERS:
app.include_router(r)
app.add_event_handler("startup", lambda: _startup(app.state.ai))
_mount_static(app)
return app
def _startup(deps: AppState) -> None:
# seed file metadata so the first request has data, then index in background
try:
deps.indexer.scan_files()
except Exception:
pass
deps.indexer.start()
deps.watcher.start()
deps.deploy_watcher.start()
# Run-exit detection: flip "running" conversations to "finished" the
# moment their sidecar-launched process dies (see _watch_sidecar_runs).
threading.Thread(target=_watch_sidecar_runs, args=(deps,),
daemon=True).start()
# Remote workers: health poller + the transcript mirror (workers.py,
# remotefeed.py). Both idle when nothing is paired.
deps.worker_monitor.start()
deps.outbox_mirror.start()
if deps.feed_mirror:
deps.feed_mirror.start()
# Cron jobs: fire scheduled agent sessions (see cron.py).
deps.cron_scheduler.start()
# OpenRouter catalogue (prices + sizes from APPE): pulled off the request
# path so the first model-browser open and the first priced transcript
# already have it (openrouter.py).
openrouter_mod.warm()
# ── static PWA + SPA fallback (declared last so /api/* wins) ─────────────────
# Everything under /assets/ carries a content hash in its name, so it can never
# go stale and is cached forever. The rest of the shell — index.html, the
# service worker, the manifest — keeps its name across deploys, so it must be
# revalidated on every request or a client can pin itself to an old bundle.
_IMMUTABLE = "public, max-age=31536000, immutable"
_REVALIDATE = "no-cache"
def _static_response(path: pathlib.Path) -> FileResponse:
hashed = path.parent.name == "assets" and path.parent.parent == STATIC_DIR
cache = _IMMUTABLE if hashed else _REVALIDATE
return FileResponse(path, headers={"Cache-Control": cache})
def _mount_static(app: FastAPI) -> None:
# Serve built assets when they exist, otherwise hand back index.html so the
# client-side router can resolve deep links like /_/.claude/skills/...
@app.get("/{full_path:path}")
def spa(full_path: str):
index = STATIC_DIR / "index.html"
if not index.is_file():
return JSONResponse({"detail": "frontend not built"}, status_code=200)
if full_path:
candidate = (STATIC_DIR / full_path).resolve()
try:
candidate.relative_to(STATIC_DIR)
except ValueError:
candidate = index # path traversal attempt → fall back
if candidate.is_dir() and (candidate / "index.html").is_file():
# Static sub-sites shipped in public/ (e.g. /avatars/hemp-henry/)
# get their own index instead of the SPA shell.
candidate = candidate / "index.html"
if candidate.is_file():
return _static_response(candidate)
return _static_response(index)
app = create_app()