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>
161 lines
5.7 KiB
Python
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()
|