"""§14 + roadmap item #30 — on-site sessions-history browser source. Sibling of `docs.py` / `philosophy.py` but with a different read shape: the bodies here live in the **public** `wiggleverse/ohm-session-history` gitea repo (transcripts of every OHM build session, published per the ohm-infra SESSION-PROTOCOL.md), not on disk. The framework mediates the gitea fetch on behalf of the browser so the rendered `/docs/sessions/*` surface inherits the same chrome as `/philosophy` and `/docs/user-guide` and stays free of any cross-origin gestures from the frontend. Three read endpoints, all anonymous-reachable: GET /api/docs/sessions/manifest — sessions.json (title manifest) GET /api/docs/sessions/about — README.md (the about page) GET /api/docs/sessions// — a transcript body GET /api/docs/sessions//index — per-session file listing All three sit behind a small in-process TTL cache (manifest TTL default 60 s, content TTL default 300 s). Negative results (404 from gitea) are also cached at the content TTL to avoid hammering gitea when a deployment hasn't yet been populated with transcripts. The cache key is the URL path on the gitea raw base (or the contents API for the per-session listing); the cache lives in-process, plain dict + `time.monotonic()` check, no external dep. Env knobs: OHM_SESSION_HISTORY_RAW_BASE Override the gitea raw base URL. Default points at OHM's canonical transcript repo: https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main The framework-default value is OHM-flavored because OHM is the only deployment to date — a deployment running its own transcript repo overrides this via flotilla's overlay. OHM_SESSION_HISTORY_CONTENTS_BASE Override the gitea contents-API base URL (for the per-session listing endpoint, which enumerates files inside a `NNNN/` folder). Default: https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC Cache TTL for the manifest (default 60 s). The manifest is small and changes when a new session is added; 60 s strikes a balance between freshness and gitea load. OHM_DOCS_SESSIONS_CONTENT_TTL_SEC Cache TTL for transcript bodies + README + per-session listings (default 300 s = 5 minutes). Transcripts are append-only once published, so 5 minutes of staleness is harmless. §3 invariant 1 is preserved: the framework holds no secret bytes; the gitea repo is public, the fetch carries no auth header. """ from __future__ import annotations import logging import os import re import threading import time from typing import Any import httpx log = logging.getLogger(__name__) _DEFAULT_RAW_BASE = ( "https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main" ) _DEFAULT_CONTENTS_BASE = ( "https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents" ) _DEFAULT_MANIFEST_TTL_SEC = 60.0 _DEFAULT_CONTENT_TTL_SEC = 300.0 # The transcript filename shape per SESSION-PROTOCOL.md §1. The # `--` suffix is optional so legacy renamed-letter # transcripts (e.g. `SESSION-0009.0-TRANSCRIPT.md` without timestamps) # remain reachable. The `\.\d+(\.\d+)*` after the session number # accommodates `0017.0`, `0017.1`, `0017.1.1`, etc. _TRANSCRIPT_FILENAME_RE = re.compile( r"^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT" r"(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}--\d{4}-\d{2}-\d{2}T\d{2}-\d{2})?" r"\.md$" ) _SESSION_DIR_RE = re.compile(r"^\d{4}$") _HTTP_TIMEOUT_SEC = 5.0 def _env_float(name: str, default: float) -> float: raw = os.environ.get(name, "").strip() if not raw: return default try: return float(raw) except ValueError: log.warning("invalid %s=%r — falling back to %s", name, raw, default) return default def _raw_base() -> str: return os.environ.get("OHM_SESSION_HISTORY_RAW_BASE", "").strip() or _DEFAULT_RAW_BASE def _contents_base() -> str: return ( os.environ.get("OHM_SESSION_HISTORY_CONTENTS_BASE", "").strip() or _DEFAULT_CONTENTS_BASE ) def _manifest_ttl() -> float: return _env_float("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", _DEFAULT_MANIFEST_TTL_SEC) def _content_ttl() -> float: return _env_float("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC) # --------------------------------------------------------------------------- # In-process TTL cache # --------------------------------------------------------------------------- # # Plain dict + `time.monotonic()` check, no external dep. The cache # value is a `(stored_at, payload)` tuple; `payload` may carry an # error-shape sentinel for negative caching (404s). Lock guards # read-modify-write across worker tasks; entries are immutable once # stored so reads under the lock are fast. _lock = threading.Lock() _cache: dict[str, tuple[float, dict[str, Any]]] = {} def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None: with _lock: entry = _cache.get(key) if entry is None: return None stored_at, payload = entry if time.monotonic() - stored_at > ttl_sec: # Don't evict here; let _cache_put overwrite on next fetch. # The stale entry is gated by the TTL check, so it stays # invisible to readers regardless. return None return payload def _cache_put(key: str, payload: dict[str, Any]) -> None: with _lock: _cache[key] = (time.monotonic(), payload) def reset_cache() -> None: """Drop every cached entry. Test seam — not called in production.""" with _lock: _cache.clear() # --------------------------------------------------------------------------- # Public fetch surface # --------------------------------------------------------------------------- # # Each fetcher returns a `{status, ...}` dict. `status` is one of: # "ok" — payload field carries the body # "404" — gitea returned 404 (or content was missing) # "error" — gitea returned 5xx, timed out, or returned malformed data # # The route layer maps these onto HTTP responses; keeping the mapping # out of this module makes the cache transparent to the test harness. async def _http_get(url: str) -> tuple[int, str]: """Perform a single GET against `url`; return (status_code, body). On timeout or network error, returns (599, error_message). The 599 pseudo-status maps to a 502 at the route layer the same way an upstream 5xx does — the caller doesn't care which leg of the network broke. """ try: async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: r = await client.get(url) return r.status_code, r.text except httpx.HTTPError as e: log.warning("gitea fetch failed for %s: %s", url, e) return 599, f"fetch error: {e}" def _is_valid_session_dir(nnnn: str) -> bool: return bool(_SESSION_DIR_RE.match(nnnn)) def _is_valid_transcript_filename(filename: str) -> bool: return bool(_TRANSCRIPT_FILENAME_RE.match(filename)) async def fetch_manifest() -> dict[str, Any]: """Fetch and parse `sessions.json` from the public repo. Returns one of: {"status": "ok", "manifest": {...}} — successful parse {"status": "404"} — gitea 404 (empty state) {"status": "error", "detail": "..."} — 5xx / timeout / bad JSON """ cache_key = "manifest" cached = _cache_get(cache_key, _manifest_ttl()) if cached is not None: return cached url = f"{_raw_base()}/sessions.json" status, body = await _http_get(url) if status == 200: try: import json data = json.loads(body) except (json.JSONDecodeError, ValueError) as e: payload: dict[str, Any] = { "status": "error", "detail": f"sessions.json malformed: {e}", } # Don't cache parse errors — give the upstream a chance to # fix the file without waiting for TTL expiry. return payload if not isinstance(data, dict): return { "status": "error", "detail": "sessions.json is not a JSON object", } payload = {"status": "ok", "manifest": data} _cache_put(cache_key, payload) return payload if status == 404: payload = {"status": "404"} _cache_put(cache_key, payload) return payload return {"status": "error", "detail": f"upstream returned {status}"} async def fetch_about() -> dict[str, Any]: """Fetch the repo's README.md (rendered as the /docs/sessions/about page). Returns one of: {"status": "ok", "body": "..."} {"status": "404"} {"status": "error", "detail": "..."} """ cache_key = "about:README.md" cached = _cache_get(cache_key, _content_ttl()) if cached is not None: return cached url = f"{_raw_base()}/README.md" status, body = await _http_get(url) if status == 200: payload: dict[str, Any] = {"status": "ok", "body": body} _cache_put(cache_key, payload) return payload if status == 404: payload = {"status": "404"} _cache_put(cache_key, payload) return payload return {"status": "error", "detail": f"upstream returned {status}"} async def fetch_transcript(nnnn: str, filename: str) -> dict[str, Any]: """Fetch a single transcript body from `{nnnn}/{filename}` in the repo. The caller is expected to have validated `nnnn` and `filename` against `_is_valid_session_dir` / `_is_valid_transcript_filename` before calling this — invalid paths shouldn't reach the network. """ cache_key = f"transcript:{nnnn}/{filename}" cached = _cache_get(cache_key, _content_ttl()) if cached is not None: return cached url = f"{_raw_base()}/{nnnn}/{filename}" status, body = await _http_get(url) if status == 200: payload: dict[str, Any] = {"status": "ok", "body": body} _cache_put(cache_key, payload) return payload if status == 404: payload = {"status": "404"} _cache_put(cache_key, payload) return payload return {"status": "error", "detail": f"upstream returned {status}"} async def fetch_session_index(nnnn: str) -> dict[str, Any]: """List the transcript filenames inside the `{nnnn}/` folder. Uses gitea's contents API (one HTTP per session-index page-view per cache-TTL) rather than the raw URL — there's no flat way to list a folder via the raw mount. Returns one of: {"status": "ok", "files": ["SESSION-...md", ...]} {"status": "404"} {"status": "error", "detail": "..."} Only filenames that match `_is_valid_transcript_filename` are surfaced — sibling files (e.g. an attached `notes.md`) are ignored so the /docs/sessions/ page never lists a non-transcript masquerading as one. """ cache_key = f"index:{nnnn}" cached = _cache_get(cache_key, _content_ttl()) if cached is not None: return cached url = f"{_contents_base()}/{nnnn}" status, body = await _http_get(url) if status == 200: try: import json data = json.loads(body) except (json.JSONDecodeError, ValueError) as e: return { "status": "error", "detail": f"contents API response malformed: {e}", } if not isinstance(data, list): return { "status": "error", "detail": "contents API returned non-list", } files: list[str] = [] for entry in data: if not isinstance(entry, dict): continue if entry.get("type") != "file": continue name = entry.get("name") if not isinstance(name, str): continue if _is_valid_transcript_filename(name): files.append(name) files.sort() payload: dict[str, Any] = {"status": "ok", "files": files} _cache_put(cache_key, payload) return payload if status == 404: payload = {"status": "404"} _cache_put(cache_key, payload) return payload return {"status": "error", "detail": f"upstream returned {status}"}