All gates re-verified against a stable tree. Report: **Verified (all 6 tasks in `complete/`; this was the final pass)** - `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`. - **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned). - **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code. - **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria. - **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
330 lines
16 KiB
Python
330 lines
16 KiB
Python
"""Brain of Reese — application entrypoint.
|
|
|
|
Boots logging + conditional debugpy, then creates the FastAPI app:
|
|
API routes first (so they win over the catch-all), and the static frontend
|
|
mounted last. No CDN: everything the browser needs is served by this
|
|
process from local files (see PLAN §UI/UX — No External Dependencies).
|
|
|
|
Phase 16 (A10 revised): before anything is served, admin auth must be
|
|
configured (fail-loud), and the app wraps every route in Starlette's
|
|
SessionMiddleware — a signed ``bor_session`` cookie is the only session
|
|
state in the system.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from starlette.responses import FileResponse, Response
|
|
|
|
from app.api.admin_settings import router as admin_settings_router
|
|
from app.api.auth import router as auth_router
|
|
from app.api.chat import reset_runtime_caches
|
|
from app.api.chat import router as chat_router
|
|
from app.api.chat_images import router as chat_images_router
|
|
from app.api.chats import (
|
|
public_router as chats_public_router,
|
|
)
|
|
from app.api.chats import (
|
|
router as chats_router,
|
|
)
|
|
from app.api.chats import (
|
|
shared_page_router as chats_shared_page_router,
|
|
)
|
|
from app.api.config import router as config_router
|
|
from app.api.doc_drafts import router as doc_drafts_router
|
|
from app.api.docs import router as docs_router
|
|
from app.api.git_sources import router as git_sources_router
|
|
from app.api.health import router as health_router
|
|
from app.api.source_labels import router as source_labels_router
|
|
from app.api.steering import router as steering_router
|
|
from app.api.suggestions import router as suggestions_router
|
|
from app.api.sync import router as sync_router
|
|
from app.api.tokens import router as tokens_router
|
|
from app.api.ui_settings import router as ui_settings_router
|
|
from app.config import get_settings, invalidate_settings
|
|
from app.core import theming
|
|
from app.core.auth import ensure_admin_configured
|
|
from app.core.caching import configure_caching
|
|
from app.core.compression import SelectiveGZipMiddleware
|
|
from app.core.debugging import configure_debugging
|
|
from app.core.logging import configure_logging
|
|
from app.core.response_budget import ResponseBudgetMiddleware
|
|
from app.core.security_headers import SecurityHeadersMiddleware
|
|
from app.core.settings_migrate import migrate_legacy_docs_creds
|
|
from app.core.settings_overlay import enabled as settings_db_enabled
|
|
from app.core.settings_overlay import load_overrides, register_invalidator
|
|
from app.db import SessionLocal
|
|
|
|
configure_logging()
|
|
configure_debugging()
|
|
|
|
settings = get_settings()
|
|
logger = logging.getLogger("app")
|
|
|
|
|
|
def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> None:
|
|
"""Phase 76: the navbar views are views of ONE shell document.
|
|
|
|
Every registered path serves ``frontend/index.html`` (the shell)
|
|
instead of its own page file: the client-side router
|
|
(``frontend/assets/router.js``) reads ``location.pathname`` at boot
|
|
and shows the matching view, so a direct load of e.g.
|
|
``/tuning.html`` deep-links to the Tuning view. Registered AFTER the
|
|
API routers and BEFORE the static catch-all mount (routes-first),
|
|
so the phase-33 caching middleware — which wraps the whole app and
|
|
already lists every one of these paths in ``HTML_PAGES`` — applies
|
|
the no-cache + ``?v=<token>`` contract to the response untouched.
|
|
The list is driven by the caller: tasks 02/03 fold the remaining
|
|
views in by extending the tuple (task 03 lands History — all four
|
|
non-chat navbar views are in; the old per-view ``.html`` files are
|
|
deleted in the same change as their shell route lands — one source
|
|
of truth).
|
|
"""
|
|
shell_file = static_dir / "index.html"
|
|
|
|
async def _shell_view() -> FileResponse:
|
|
return FileResponse(shell_file, media_type="text/html")
|
|
|
|
for path in paths:
|
|
# GET (document loads, the browser path) + HEAD — the pre-fold
|
|
# static file answered both, so the shell route keeps that
|
|
# method parity (the body is the same FileResponse; HEAD ships
|
|
# headers only).
|
|
app.api_route(
|
|
path, methods=["GET", "HEAD"], include_in_schema=False
|
|
)(_shell_view)
|
|
|
|
|
|
def _favicon() -> Response:
|
|
"""Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
|
|
favicon over the wire.
|
|
|
|
Renders :func:`app.core.theming.favicon_svg` from the EFFECTIVE
|
|
palette on EVERY request — one short-lived ``SessionLocal()``
|
|
session, the ``caching.py`` middleware's house pattern: NO process
|
|
cache, so a runtime theme change (the admin Theme tab's Save) is
|
|
visible on the next request without a restart (a single-row SELECT
|
|
is negligible at homelab traffic). ANY exception (a DB blip, a
|
|
pre-migration boot) falls back to the BUILT-IN palette with 200 —
|
|
never 500 (the theme-tag's never-break contract, ``caching.py``'s
|
|
DB-blip fallback): the tab icon degrades to the built-in art, the
|
|
app never errors on it.
|
|
|
|
Deliberately OUTSIDE ``/api/*``: no per-response byte-budget line
|
|
(AGENTS rule 10 is ``/api/*``-only) and NO auth dependency — the
|
|
favicon is chrome, public like the login page.
|
|
``CachingMiddleware`` passes the response through untouched
|
|
(``/favicon.svg`` is neither in ``HTML_PAGES`` nor under
|
|
``/assets/``) → the route's own ``Cache-Control: no-cache`` is the
|
|
freshness contract (the palette changes at runtime; the pages are
|
|
no-cache too, so the browser revalidates the favicon together with
|
|
them — freshness is revalidation, not a ``?v=`` bust). The route is
|
|
not under ``/assets/``, so its ``<link>`` ref can never be touched
|
|
by the ``?v=`` rewrite. ``SecurityHeadersMiddleware`` still adds the
|
|
CSP — no change needed (the phase-123 ``img-src 'self' data:``
|
|
carve-out covers task 04's ``data:`` preview; CSP3 governs
|
|
``link[rel=icon]`` by ``img-src``).
|
|
"""
|
|
try:
|
|
db = SessionLocal()
|
|
try:
|
|
effective = theming.effective_settings(db)
|
|
finally:
|
|
db.close()
|
|
svg = theming.favicon_svg(effective)
|
|
except Exception:
|
|
logger.exception(
|
|
"favicon: theme read failed — serving the built-in favicon"
|
|
)
|
|
svg = theming.favicon_svg(theming.BUILTIN_COLORS)
|
|
return Response(
|
|
svg,
|
|
media_type="image/svg+xml",
|
|
headers={"Cache-Control": "no-cache"},
|
|
)
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
# Fail loud BEFORE serving anything (phase 16): missing
|
|
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
|
# variable(s) — the app never starts in a half-authenticated state.
|
|
ensure_admin_configured(settings)
|
|
|
|
# Phase 168 (task 02, D2/D3): prime the DB-over-env resolver now
|
|
# that auth is verified. The module-level ``settings`` above stays
|
|
# the pre-boot value the middleware wiring below uses (LOCKED A4:
|
|
# those boot-wired settings apply on the NEXT boot); every consumer
|
|
# that calls get_settings() from here on resolves DB-over-env — no
|
|
# restart. invalidate_settings() runs the runtime reset hooks
|
|
# (task 03) and clears the cache; a boot-time DB miss is fail-soft
|
|
# (one WARNING, env values in effect) and the next resolution
|
|
# retries, so the prime can never cache an env-only view forever.
|
|
invalidate_settings()
|
|
if settings_db_enabled():
|
|
get_settings()
|
|
logger.info("settings overrides loaded count=%d", len(load_overrides()))
|
|
else:
|
|
get_settings()
|
|
logger.info("settings overrides loaded source=env-only disabled")
|
|
|
|
# Phase 173 (task 04, C3): the one-time startup migration — a
|
|
# legacy credentials-bearing ``docs_repo`` (env or DB) is split
|
|
# into ``app_settings`` rows (clean repo + ``docs_user`` +
|
|
# ``docs_token``); the DB rows shadow the env, ``.env`` is never
|
|
# rewritten, a second run is a no-op. Called ONCE, after the DB is
|
|
# available; a migration hiccup logs a WARNING and never blocks
|
|
# startup — the app boots with the pre-phase behavior (the
|
|
# migration is a convenience, not a dependency).
|
|
try:
|
|
migrate_legacy_docs_creds()
|
|
except Exception as exc: # noqa: BLE001 — boot must never block
|
|
logger.warning("settings migration failed (%s)", type(exc).__name__)
|
|
|
|
# Phase 168 (task 03): the settings panel's Save must retune the
|
|
# running process, and app/core must never import app/api (PLAN §3's
|
|
# direction) — so create_app(), the composition root, wires the
|
|
# reset hook. It runs on every invalidate_settings() after the cache
|
|
# clear: the next chat turn rebuilds the shared LLMClient and the
|
|
# concurrency semaphore from the CURRENT settings (no restart), while
|
|
# in-flight turns keep the client they started with (A12 semantics).
|
|
register_invalidator(reset_runtime_caches)
|
|
logger.info("settings invalidator registered name=chat_runtime_caches")
|
|
|
|
app = FastAPI(title=settings.app_name, version=settings.app_version)
|
|
|
|
# Signed single-admin session cookie (Starlette middleware, itsdangerous
|
|
# signer — no server-side store, no new services). Homelab HTTP: same_site
|
|
# is "lax" and https_only stays off (documented in the README).
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=settings.session_secret,
|
|
session_cookie=settings.session_cookie,
|
|
max_age=settings.session_max_age,
|
|
same_site="lax",
|
|
https_only=False,
|
|
)
|
|
|
|
# API routes first so they take precedence over the catch-all static mount.
|
|
app.include_router(health_router, prefix="/api")
|
|
app.include_router(config_router, prefix="/api")
|
|
app.include_router(auth_router, prefix="/api")
|
|
app.include_router(suggestions_router, prefix="/api")
|
|
app.include_router(docs_router, prefix="/api")
|
|
app.include_router(git_sources_router, prefix="/api")
|
|
app.include_router(chat_router, prefix="/api")
|
|
# Phase 123: the question-image upload/serve pair (POST is
|
|
# user-gated like the chat turn; GET is public like saved-chat
|
|
# content — the uuid filename is the credential).
|
|
app.include_router(chat_images_router, prefix="/api")
|
|
app.include_router(steering_router, prefix="/api")
|
|
app.include_router(sync_router, prefix="/api")
|
|
app.include_router(chats_router, prefix="/api")
|
|
app.include_router(doc_drafts_router, prefix="/api")
|
|
# Phase 79: the admin token surface (create/list/revoke) — admin-only
|
|
# (router-wide require_admin; a token USER stays 403 here, task 03).
|
|
app.include_router(tokens_router, prefix="/api")
|
|
# Phase 91 (task 01): the admin UI-settings surface (GET/PUT the
|
|
# single ui_settings row — the Theme tab's persistence) — admin-only
|
|
# (router-wide require_admin; anonymous AND token users stay 403).
|
|
app.include_router(ui_settings_router, prefix="/api")
|
|
# Phase 168 (task 04): the admin settings panel surface (read/write
|
|
# the app_settings overrides — DB-over-env, live on Save) — admin-only
|
|
# (router-wide require_admin; a token user stays 403).
|
|
app.include_router(admin_settings_router, prefix="/api")
|
|
# Phase 191 (task 02): the per-source name/label/tags surface (the
|
|
# catalogue GET + the upsert + the reset) — admin-only (router-wide
|
|
# require_admin; a token user stays 403). The display name is an
|
|
# ALIAS beside the derived source name, never a rename (D1).
|
|
app.include_router(source_labels_router, prefix="/api")
|
|
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
|
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
|
# page route below, registered without a prefix) is the page.
|
|
app.include_router(chats_public_router, prefix="/api")
|
|
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
|
|
|
|
# Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
|
|
# favicon route — registered AFTER the router includes and BEFORE
|
|
# the static catch-all mount (routes-first, the phase-51/76
|
|
# pattern): a file named favicon.svg in the static dir could never
|
|
# shadow it. HEAD joins GET for static-file parity (the
|
|
# phase-76 ``_shell_routes`` parity idiom — the pre-phase static
|
|
# file answered both methods, the dynamic route keeps that).
|
|
# See the handler docstring for the never-break DB-blip fallback
|
|
# and the /api/*-out rationale.
|
|
app.api_route(
|
|
"/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False
|
|
)(_favicon)
|
|
|
|
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
|
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
|
# Added after the session middleware, so it wraps the whole app
|
|
# (including the static catch-all below); /api/* — the SSE chat
|
|
# stream in particular — passes through untouched.
|
|
configure_caching(app)
|
|
|
|
# Phase 167 (task 05, TODO L5): the byte budget — ONE
|
|
# ``api method=… path=… status=… bytes=… enc=… duration_ms=…`` line per
|
|
# /api/* response (INFO at/over BOR_LOG_LARGE_RESPONSE_BYTES, DEBUG
|
|
# below), so "which endpoint is heavy, right now" is a
|
|
# ``grep ' :: api '`` away. Position: added BEFORE the gzip call below,
|
|
# i.e. INSIDE it (SecurityHeaders → gzip → response-budget → caching →
|
|
# session → routes), so ``bytes=`` is the bytes the ROUTE produced
|
|
# (uncompressed) while ``enc=`` reports what went on the wire — the two
|
|
# together answer "how fat is it, and how fat did the client see it".
|
|
# Pure ASGI (never BaseHTTPMiddleware): the SSE stream is counted as it
|
|
# passes and logged once, when the stream ends.
|
|
app.add_middleware(ResponseBudgetMiddleware)
|
|
|
|
# Phase 167 (TODO L5): selective gzip — every response except the
|
|
# POST /api/chat SSE stream (skipped by EXACT path, A15) compresses
|
|
# when it is >= compress_min_bytes (the 2 MB /api/docs/tree defect).
|
|
# 0 = the feature is OFF: no middleware is added at all (the
|
|
# kill-switch convention). Position: added AFTER configure_caching but
|
|
# BEFORE the SecurityHeaders line below, so SecurityHeaders stays
|
|
# OUTERMOST (A20, the last add_middleware is outermost) and gzip sits
|
|
# outside the caching layer — an already-rewritten HTML page compresses
|
|
# too, and /api/* still passes the caching layer byte-identically
|
|
# before compression.
|
|
if settings.compress_min_bytes > 0:
|
|
app.add_middleware(
|
|
SelectiveGZipMiddleware, minimum_size=settings.compress_min_bytes
|
|
)
|
|
|
|
# Phase 82: security headers (CSP + no-framing + nosniff) — outermost on
|
|
# purpose (last add_middleware): every response carries them, including
|
|
# the static catch-all's 404s below (audit SEC-04).
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
static_dir = Path(settings.static_dir).resolve()
|
|
if static_dir.is_dir():
|
|
# Phase 76: the folded navbar views serve the shell — the
|
|
# router picks the view from the pathname. Task 01 landed
|
|
# Tuning; task 02 folds RAG + Sources; task 03 lands History
|
|
# (list-driven — all four non-chat navbar views are in);
|
|
# phase 79 task 06 folds the sixth view (Tokens).
|
|
_shell_routes(
|
|
app,
|
|
static_dir,
|
|
(
|
|
"/tuning.html",
|
|
"/sources.html",
|
|
"/git-sources.html",
|
|
"/history.html",
|
|
"/tokens.html", # phase 79 task 06: the Tokens view
|
|
"/theme.html", # phase 91 task 04: the Theme view (shell route)
|
|
"/settings.html", # phase 168 task 05: the Settings view (shell route)
|
|
),
|
|
)
|
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
|
else:
|
|
logger.warning("static dir %s not found — serving API only", static_dir)
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|