From 63fec4371ac2c9737ba09f6f329e6badc25c7066 Mon Sep 17 00:00:00 2001 From: Apple Date: Tue, 3 Mar 2026 04:35:18 -0800 Subject: [PATCH] feat(sofiia-console): add runbooks index status endpoint GET /api/runbooks/status returns docs_root, indexed_files, indexed_chunks, last_indexed_at, fts_available; docs_index_meta table and set on rebuild Made-with: Cursor --- services/sofiia-console/app/db.py | 4 ++ services/sofiia-console/app/docs_index.py | 5 +- services/sofiia-console/app/docs_store.py | 50 ++++++++++++++++++- .../sofiia-console/app/runbooks_router.py | 8 +++ tests/test_sofiia_docs_search.py | 20 ++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/services/sofiia-console/app/db.py b/services/sofiia-console/app/db.py index b2f4a864..790d529d 100644 --- a/services/sofiia-console/app/db.py +++ b/services/sofiia-console/app/db.py @@ -374,6 +374,10 @@ CREATE VIRTUAL TABLE IF NOT EXISTS docs_chunks_fts USING fts5( content, content='' ); +CREATE TABLE IF NOT EXISTS docs_index_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); -- ── Graph Intelligence (Hygiene + Reflection) ────────────────────────────── -- These ADD COLUMN statements are idempotent (IF NOT EXISTS requires SQLite 3.37+). diff --git a/services/sofiia-console/app/docs_index.py b/services/sofiia-console/app/docs_index.py index 81b5af3d..f52fe43f 100644 --- a/services/sofiia-console/app/docs_index.py +++ b/services/sofiia-console/app/docs_index.py @@ -9,11 +9,12 @@ import asyncio import logging import os import sys +from datetime import datetime, timezone from pathlib import Path from typing import Optional from . import db as _db -from .docs_store import clear_docs_index, insert_docs_file +from .docs_store import clear_docs_index, insert_docs_file, set_docs_index_meta logger = logging.getLogger(__name__) @@ -52,6 +53,8 @@ async def rebuild_index(docs_root: Optional[Path] = None) -> int: mtime = path.stat().st_mtime await insert_docs_file(path_key, mtime, content) count += 1 + last_indexed_at = datetime.now(timezone.utc).isoformat() + await set_docs_index_meta(str(root), last_indexed_at, sha="") logger.info("Docs index rebuilt: %s files from %s", count, root) return count diff --git a/services/sofiia-console/app/docs_store.py b/services/sofiia-console/app/docs_store.py index 6078f871..408059d0 100644 --- a/services/sofiia-console/app/docs_store.py +++ b/services/sofiia-console/app/docs_store.py @@ -37,15 +37,63 @@ def _extract_title(content: str, path: str) -> str: async def clear_docs_index() -> None: - """Remove all docs_files, docs_chunks, and FTS rows.""" + """Remove all docs_files, docs_chunks, FTS rows, and index meta.""" conn = await _db.get_db() await conn.execute("DELETE FROM docs_chunks_fts") await conn.execute("DELETE FROM docs_chunks") await conn.execute("DELETE FROM docs_files") + await conn.execute("DELETE FROM docs_index_meta") await conn.commit() logger.info("Docs index cleared.") +async def set_docs_index_meta(docs_root: str, last_indexed_at: str, sha: str = "") -> None: + """Write meta after rebuild. last_indexed_at: ISO or epoch string.""" + conn = await _db.get_db() + for key, value in [("docs_root", docs_root), ("last_indexed_at", last_indexed_at), ("sha", sha)]: + await conn.execute( + "INSERT OR REPLACE INTO docs_index_meta(key, value) VALUES (?,?)", + (key, value), + ) + await conn.commit() + + +async def get_docs_index_status() -> Dict[str, Any]: + """Return indexed_files, indexed_chunks, last_indexed_at, docs_root, fts_available.""" + conn = await _db.get_db() + files_row = None + chunks_row = None + async with conn.execute("SELECT COUNT(*) FROM docs_files") as cur: + files_row = await cur.fetchone() + async with conn.execute("SELECT COUNT(*) FROM docs_chunks") as cur: + chunks_row = await cur.fetchone() + indexed_files = int(files_row[0]) if files_row else 0 + indexed_chunks = int(chunks_row[0]) if chunks_row else 0 + + meta = {} + async with conn.execute("SELECT key, value FROM docs_index_meta") as cur: + async for row in cur: + meta[row[0]] = row[1] + last_indexed_at = meta.get("last_indexed_at") or None + docs_root = meta.get("docs_root") or "" + + fts_available = False + if indexed_chunks > 0: + try: + async with conn.execute("SELECT 1 FROM docs_chunks_fts LIMIT 1") as cur: + fts_available = (await cur.fetchone()) is not None + except Exception: + pass + + return { + "docs_root": docs_root, + "indexed_files": indexed_files, + "indexed_chunks": indexed_chunks, + "last_indexed_at": last_indexed_at, + "fts_available": fts_available, + } + + async def insert_docs_file(path: str, mtime: float, content: str) -> None: """Register one file and its chunks. Caller ensures path is normalized.""" conn = await _db.get_db() diff --git a/services/sofiia-console/app/runbooks_router.py b/services/sofiia-console/app/runbooks_router.py index cb80f93d..1c2fbd89 100644 --- a/services/sofiia-console/app/runbooks_router.py +++ b/services/sofiia-console/app/runbooks_router.py @@ -15,6 +15,14 @@ from . import docs_store as store runbooks_router = APIRouter(prefix="/api/runbooks", tags=["runbooks-docs"]) +@runbooks_router.get("/status") +async def runbooks_status( + _auth: str = Depends(require_auth), +): + """Return docs index status: indexed_files, indexed_chunks, last_indexed_at, docs_root, fts_available.""" + return await store.get_docs_index_status() + + def _safe_path(path: str) -> bool: """Reject path traversal and non-docs paths.""" if not path or ".." in path or path.startswith("/"): diff --git a/tests/test_sofiia_docs_search.py b/tests/test_sofiia_docs_search.py index 3d1797fb..d892df59 100644 --- a/tests/test_sofiia_docs_search.py +++ b/tests/test_sofiia_docs_search.py @@ -93,3 +93,23 @@ def test_runbooks_raw_400_for_invalid_path(sofiia_client): """Raw returns 400 for path traversal attempt.""" r = sofiia_client.get("/api/runbooks/raw?path=../../../etc/passwd") assert r.status_code == 400 + + +def test_runbooks_status_after_rebuild(sofiia_module, tmp_path, tmp_docs_with_rehearsal, monkeypatch): + """After rebuild, status shows indexed_files > 0, indexed_chunks > 0, last_indexed_at set.""" + import app.docs_index as docs_index_mod + import app.docs_store as docs_store_mod + + monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) + loop = asyncio.get_event_loop() + + async def run(): + await docs_index_mod.rebuild_index(tmp_docs_with_rehearsal) + return await docs_store_mod.get_docs_index_status() + + status = loop.run_until_complete(run()) + assert status["indexed_files"] >= 1, status + assert status["indexed_chunks"] >= 1, status + assert status.get("last_indexed_at") is not None, status + assert "docs_root" in status + assert "fts_available" in status