diff --git a/chat/app.py b/chat/app.py index 9e2c74b..0ae516c 100644 --- a/chat/app.py +++ b/chat/app.py @@ -33,6 +33,7 @@ from chat.web.kickoff import router as kickoff_router from chat.web.middleware import FirstRunRedirectMiddleware from chat.web.nav import router as nav_router from chat.web.settings import router as settings_router +from chat.web.snapshots import router as snapshots_router from chat.web.sse import router as sse_router from chat.web.turns import router as turns_router @@ -137,6 +138,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException): app.include_router(bots_router) app.include_router(kickoff_router) app.include_router(settings_router) +app.include_router(snapshots_router) app.include_router(nav_router) app.include_router(chat_router) app.include_router(drawer_router) diff --git a/chat/templates/layout.html b/chat/templates/layout.html index 7b1954b..197a39b 100644 --- a/chat/templates/layout.html +++ b/chat/templates/layout.html @@ -5,6 +5,7 @@ diff --git a/chat/templates/snapshots.html b/chat/templates/snapshots.html new file mode 100644 index 0000000..6039c16 --- /dev/null +++ b/chat/templates/snapshots.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} +{% block title %}Snapshots - chat{% endblock %} +{% block content %} + + +{% if preview %} +
+

Preview: {{ preview.snapshot_id }}

+
+
kind
{{ preview.kind }}
+
filename
{{ preview.filename }}
+
file size (bytes)
{{ preview.file_size_bytes }}
+
snapshot last_event_id
{{ preview.last_event_id }}
+
current event_log max id
{{ preview.current_event_log_max_id }}
+
events since snapshot
{{ preview.event_delta }}
+
events stored in snapshot
{{ preview.event_log_rows_in_snapshot }}
+
+
+{% endif %} + +{% if snapshots %} + + + + + + + + + + + + + {% for snap in snapshots %} + + + + + + + + + {% endfor %} + +
IDKindCreated (UTC)Size (bytes)last_event_idActions
{{ snap.snapshot_id }}{{ snap.kind }}{{ snap.created_at }}{{ snap.file_size_bytes }}{{ snap.last_event_id if snap.last_event_id is not none else '?' }} + Preview +
+ Restore +
+ + + +
+
+
+{% else %} +

No snapshots yet. Use "Take snapshot now" to create one.

+{% endif %} +{% endblock %} diff --git a/chat/web/snapshots.py b/chat/web/snapshots.py new file mode 100644 index 0000000..ae3cc30 --- /dev/null +++ b/chat/web/snapshots.py @@ -0,0 +1,190 @@ +"""Snapshot UX routes (T99). + +Surfaces the existing snapshot service (``chat/services/snapshot.py``) +through HTML so the user can see, take, restore, and preview snapshots +without dropping to a shell. + +Routes: + +* ``GET /snapshots`` list all snapshots (both kinds) +* ``POST /snapshots/take`` take a periodic snapshot now +* ``POST /snapshots/restore/{id}`` restore (requires matching ``confirm_id``) +* ``GET /snapshots/{id}/preview`` show metadata + delta vs current + +The ``snapshot_id`` is the filename stem (the UTC timestamp written by +:func:`chat.services.snapshot.take_snapshot`) — there's no separate UUID, +and the timestamp filename is already unique per snapshot kind. Both +periodic and rewind snapshots share the same id space lookup-wise, so +the restore + preview routes accept ``kind`` as a form/query param to +disambiguate. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from chat.services.snapshot import ( + restore_from_snapshot, + take_snapshot, +) +from chat.web.bots import get_conn + +TEMPLATES = Jinja2Templates( + directory=str(Path(__file__).resolve().parent.parent / "templates") +) + +router = APIRouter() + +SNAPSHOT_KINDS = ("periodic", "rewind") + + +def _list_all_snapshots(data_dir: Path) -> list[dict]: + """Walk ``data/snapshots/{kind}/`` for both kinds and collect metadata. + + Each entry exposes the fields the template needs: ``snapshot_id`` + (filename stem), ``kind``, ``created_at`` (file mtime as ISO), the + on-disk ``file_size_bytes``, and the snapshot's stored + ``last_event_id`` (parsed from the JSON body — small enough that + listing isn't a performance concern for the handful of files we keep). + """ + from datetime import datetime, timezone + + rows: list[dict] = [] + for kind in SNAPSHOT_KINDS: + snap_dir = data_dir / "snapshots" / kind + if not snap_dir.exists(): + continue + for path in sorted(snap_dir.glob("*.json")): + try: + dump = json.loads(path.read_text()) + last_event_id = dump.get("last_event_id", 0) + except (OSError, json.JSONDecodeError): + # Corrupt or unreadable files still get listed so the + # user can see and delete them; just don't crash here. + last_event_id = None + stat = path.stat() + rows.append( + { + "snapshot_id": path.stem, + "kind": kind, + "created_at": datetime.fromtimestamp( + stat.st_mtime, tz=timezone.utc + ).isoformat(), + "file_size_bytes": stat.st_size, + "last_event_id": last_event_id, + "filename": path.name, + } + ) + # Newest first for display. + rows.sort(key=lambda r: r["created_at"], reverse=True) + return rows + + +def _resolve_snapshot_path( + data_dir: Path, snapshot_id: str, kind: str +) -> Path: + """Map an ``(id, kind)`` pair to the on-disk file, or 404.""" + if kind not in SNAPSHOT_KINDS: + raise HTTPException(status_code=400, detail=f"unknown kind: {kind}") + path = data_dir / "snapshots" / kind / f"{snapshot_id}.json" + if not path.exists(): + raise HTTPException(status_code=404, detail="snapshot not found") + return path + + +@router.get("/snapshots", response_class=HTMLResponse) +async def snapshots_list(request: Request): + settings = request.app.state.settings + rows = _list_all_snapshots(settings.data_dir) + return TEMPLATES.TemplateResponse( + request, + "snapshots.html", + {"snapshots": rows, "active_nav": "snapshots"}, + ) + + +@router.post("/snapshots/take") +async def snapshots_take(request: Request, conn=Depends(get_conn)): + """Take a periodic snapshot now. + + We use ``kind="periodic"`` for manual snapshots since they're + user-initiated checkpoints, not pre-rewind safety dumps. They count + against the 5-snapshot retention but that's fine — manual ones are + the most recent so they're the last to be pruned. + """ + settings = request.app.state.settings + take_snapshot(conn, data_dir=settings.data_dir, kind="periodic") + return RedirectResponse(url="/snapshots", status_code=303) + + +@router.post("/snapshots/restore/{snapshot_id}") +async def snapshots_restore( + snapshot_id: str, + request: Request, + confirm_id: str = Form(""), + kind: str = Form("periodic"), + conn=Depends(get_conn), +): + """Hard-confirm restore: ``confirm_id`` must equal the path id. + + Mismatched confirm → 400 (without touching the DB). On match, the + existing :func:`restore_from_snapshot` clears projected tables and + re-loads them from the dump. + """ + if confirm_id != snapshot_id: + raise HTTPException( + status_code=400, + detail="confirm_id does not match snapshot id", + ) + settings = request.app.state.settings + path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind) + restore_from_snapshot(conn, path) + return RedirectResponse(url="/snapshots", status_code=303) + + +@router.get("/snapshots/{snapshot_id}/preview", response_class=HTMLResponse) +async def snapshots_preview( + snapshot_id: str, + request: Request, + kind: str = "periodic", + conn=Depends(get_conn), +): + """Show snapshot metadata + a basic delta against the current event log. + + Phase 4 keeps this simple: the snapshot's ``last_event_id`` plus the + current ``MAX(event_log.id)`` is enough to tell the user how far the + log has moved on. A richer per-table diff is a Phase 4.5+ concern. + """ + settings = request.app.state.settings + path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind) + dump = json.loads(path.read_text()) + last_event_id = dump.get("last_event_id", 0) + + cur = conn.execute("SELECT MAX(id) FROM event_log") + row = cur.fetchone() + current_max_id = row[0] if row[0] is not None else 0 + + stat = path.stat() + return TEMPLATES.TemplateResponse( + request, + "snapshots.html", + { + "snapshots": _list_all_snapshots(settings.data_dir), + "active_nav": "snapshots", + "preview": { + "snapshot_id": snapshot_id, + "kind": kind, + "filename": path.name, + "file_size_bytes": stat.st_size, + "last_event_id": last_event_id, + "current_event_log_max_id": current_max_id, + "event_delta": current_max_id - last_event_id, + "event_log_rows_in_snapshot": len(dump.get("event_log", [])), + }, + }, + ) diff --git a/tests/test_snapshot_ux.py b/tests/test_snapshot_ux.py new file mode 100644 index 0000000..347f9ce --- /dev/null +++ b/tests/test_snapshot_ux.py @@ -0,0 +1,182 @@ +"""Tests for Task 99 — snapshot UX (manual trigger + list + restore + preview). + +Phase 4 surfaces the existing snapshot infrastructure (Phase 1 T20 / T31) +through HTML routes so the user can: + +* see what snapshots exist, +* take one on demand, +* restore one with a hard confirm, +* peek at metadata before restoring. + +The underlying service API lives in ``chat/services/snapshot.py`` and is +already exercised by ``test_snapshot.py``; here we only verify the web +surface wires the existing functions correctly. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "fancy", + "voice_samples": ["sample"], + "traits": ["shy"], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """A TestClient whose db + data_dir live under ``tmp_path``. + + ``load_settings`` derives ``data_dir`` from ``CHAT_DB_PATH``'s parent + when ``CHAT_DATA_DIR`` is unset (see ``chat/config.py``), so this also + isolates the ``data/snapshots/`` tree to ``tmp_path``. + """ + config_path = tmp_path / "config.toml" + config_path.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) + + with TestClient(app) as c: + c.tmp_path = tmp_path # type: ignore[attr-defined] + yield c + + +def _seed_bot(db_path: Path, bot_id: str = "bot_a", name: str = "BotA") -> None: + with open_db(db_path) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload(bot_id, name)) + project(conn) + + +def _take_snapshot_via_service( + db_path: Path, data_dir: Path, kind: str = "periodic" +) -> Path: + from chat.services.snapshot import take_snapshot + + with open_db(db_path) as conn: + return take_snapshot(conn, data_dir=data_dir, kind=kind) + + +def test_list_snapshots_renders_page(client, tmp_path): + _seed_bot(tmp_path / "test.db", "bot_a", "BotA") + # Take two snapshots through the service so the listing has rows. + p1 = _take_snapshot_via_service(tmp_path / "test.db", tmp_path, kind="periodic") + p2 = _take_snapshot_via_service(tmp_path / "test.db", tmp_path, kind="rewind") + + response = client.get("/snapshots") + assert response.status_code == 200 + body = response.text + # Both filenames should appear in the listing. + assert p1.stem in body + assert p2.stem in body + # Both kinds should be visible. + assert "periodic" in body + assert "rewind" in body + + +def test_take_snapshot_creates_new(client, tmp_path): + _seed_bot(tmp_path / "test.db", "bot_a", "BotA") + snapshot_dir = tmp_path / "snapshots" / "periodic" + + before = ( + len(list(snapshot_dir.glob("*.json"))) if snapshot_dir.exists() else 0 + ) + response = client.post("/snapshots/take", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/snapshots" + + after = len(list(snapshot_dir.glob("*.json"))) + assert after == before + 1 + + +def test_restore_snapshot_with_correct_confirm(client, tmp_path): + db_path = tmp_path / "test.db" + _seed_bot(db_path, "bot_a", "BotA") + snapshot_path = _take_snapshot_via_service( + db_path, tmp_path, kind="periodic" + ) + snapshot_id = snapshot_path.stem # filename without extension + + # Mutate the DB after the snapshot was taken — restoring should erase + # the new bot. + with open_db(db_path) as conn: + append_event( + conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB") + ) + project(conn) + bots_before = conn.execute( + "SELECT id FROM bots ORDER BY id" + ).fetchall() + assert {r[0] for r in bots_before} == {"bot_a", "bot_b"} + + response = client.post( + f"/snapshots/restore/{snapshot_id}", + data={"confirm_id": snapshot_id, "kind": "periodic"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + with open_db(db_path) as conn: + bots_after = conn.execute( + "SELECT id FROM bots ORDER BY id" + ).fetchall() + # The post-snapshot bot should be gone. + assert {r[0] for r in bots_after} == {"bot_a"} + + +def test_restore_snapshot_wrong_confirm_400(client, tmp_path): + db_path = tmp_path / "test.db" + _seed_bot(db_path, "bot_a", "BotA") + snapshot_path = _take_snapshot_via_service( + db_path, tmp_path, kind="periodic" + ) + snapshot_id = snapshot_path.stem + + response = client.post( + f"/snapshots/restore/{snapshot_id}", + data={"confirm_id": "not_the_right_id", "kind": "periodic"}, + follow_redirects=False, + ) + assert response.status_code == 400 + + +def test_preview_renders_metadata(client, tmp_path): + db_path = tmp_path / "test.db" + _seed_bot(db_path, "bot_a", "BotA") + snapshot_path = _take_snapshot_via_service( + db_path, tmp_path, kind="periodic" + ) + snapshot_id = snapshot_path.stem + + # Append more events post-snapshot so the delta is non-zero. + with open_db(db_path) as conn: + append_event( + conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB") + ) + project(conn) + + response = client.get( + f"/snapshots/{snapshot_id}/preview", params={"kind": "periodic"} + ) + assert response.status_code == 200 + body = response.text + assert snapshot_id in body + # Snapshot's last_event_id and current event_log size should appear. + dump = json.loads(snapshot_path.read_text()) + assert str(dump["last_event_id"]) in body