16 KiB
Session 0017.1 — Subsession transcript
Parent: SESSION-0017.0-TRANSCRIPT-…md (not yet written; driver writes at end of session 0017.0) Date: 2026-05-28 Goal: rfc-app v0.19.0 — /docs/sessions browser + flyout nav (roadmap item #30) Outcome: Shipped on branch
feature/v0.19.0-docs-sessions-browserat477f496. Backend mediates/api/docs/sessions/{manifest,about, <NNNN>/index, <NNNN>/<file>}with an in-process TTL cache (60s manifest, 5min content; both env-tunable + negative-cached on 404). Frontend reorganizes/docsinto a left-flyout hub with the new session browser sub-routes; existing DOCS.md content moves to/docs/user-guide. 313/313 backend tests pass (18 new); frontend builds clean. No tag, no pin bump, no benstull mirror push — driver takes over after subsession 0017.2 lands.
Pre-session state
rfc-appwas atmain @ ac3513a, VERSION0.18.0, working tree clean. Two remotes:origin→git.wiggleverse.org/ben.stull/rfc-app(canonical)benstull→git.benstull.org/benstull/rfc-app(mirror)
- The most recent release on
mainwas v0.18.0 (email + webhook hygiene, roadmap items #18 + #20). - Existing
/docssurface was the v0.14.0 single-route DOCS.md shape:components/Docs.jsxrendering markdown via the sharedMarkdownPreview(marked + lazy-loaded mermaid). Backend served the body at/api/docsfrom a disk-cachedDOCS.mdat the repo root. - Dispatch settled two design choices before this subsession opened:
the transcript repo is gitea-only (canonical at
git.wiggleverse.org/wiggleverse/ohm-session-history, no GitHub mirror) and the rendering shape is runtime fetch with cache (no build-time bundling of transcripts).
Turn-by-turn arc
Arc 1 — Orientation
Read ~/git/ohm-infra/SESSION-PROTOCOL.md (full), rfc-app/CLAUDE.md,
the SPEC §21 analytics chapter (DOM patterns + the autocapture
attribute conventions), backend/app/docs.py and backend/app/api.py
(to see how the existing v0.14.0 /api/docs was wired), and
backend/tests/test_propose_vertical.py for the app_with_fake_gitea
fixture shape that every existing test reuses.
Noted that pyproject.toml doesn't exist in rfc-app (the dispatch
prompt's "bump pyproject.toml" instruction was a non-op — there's a
backend/requirements.txt only, and the canonical version sources
are VERSION + frontend/package.json#version per
SPEC §20.1).
Arc 2 — Design choice: per-session listing
The dispatch prompt named the open design question for the per-session
index page (/docs/sessions/:nnnn). Two options:
- Extend the manifest endpoint to also list per-session filenames via the gitea contents API.
- Add a separate per-session listing endpoint that hits gitea contents only when the session-index page is visited.
I went with option 2 (separate /api/docs/sessions/<NNNN>/index
endpoint). Reasoning: the manifest stays cheap and small (one fetch,
one cache entry, 60s TTL); per-session listings are fetched on
demand and cached at the content TTL (5min). This means a user who
only browses the flyout's session list (via the manifest) doesn't
pay for N extra gitea contents-API calls — only the user who
clicks into a session pays. The endpoint filters the contents-API
response to entries matching the _TRANSCRIPT_FILENAME_RE so any
sibling files in a NNNN/ folder (e.g. an attached notes.md)
don't show up in the list.
Arc 3 — Design choice: negative caching for 404s
The dispatch prompt left negative caching for 404s as optional. Decided to implement it: cache 404 results at the content TTL across the manifest, about, transcript, and session-index endpoints.
Reasoning: at v0.19.0 ship time, the parallel
ohm-session-history repo restructure (subsession 0017.2) hasn't
shipped yet — the repo is still flat, so the manifest, about,
session-index, and per-transcript fetches will all 404 from gitea
for the first few hours / days. Without negative caching, every
page-view in that window would trigger a fresh gitea request even
though we already know it's 404. With negative caching, repeated
visits to a known-missing transcript only hit gitea once per
5-minute window. Manifest 404 cache means the first OHM user
loading /docs/sessions/* doesn't hammer the upstream while
0017.2 is still mid-flight.
The behavior is exercised by test_transcript_404_is_cached in
the new test file.
Arc 4 — Backend slice
Wrote backend/app/docs_sessions.py (the fetcher + cache module)
and added four routes to backend/app/api.py:
GET /api/docs/sessions/manifest— returnssessions.jsonas a JSON object. 404 from gitea →{}at HTTP 200 (empty-state contract, lets the frontend short-circuit without an error banner). 5xx / timeout → HTTP 502 with{detail: {error, detail}}.GET /api/docs/sessions/about— returns README.md astext/markdown; charset=utf-8. 404 → HTTP 404; 5xx → 502.GET /api/docs/sessions/{nnnn}/index— returns{"files": [...]}. The path paramnnnnmust match^\d{4}$; otherwise 400 before any network call. 404 from gitea (no such folder) → HTTP 404.GET /api/docs/sessions/{nnnn}/{filename}— returns the transcript body. Both path params are regex-validated before the network call (^\d{4}$for nnnn,^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT(-<start>--<end>)?\.md$for filename —<start>--<end>is optional so legacy renamed- letter transcripts without timestamps remain reachable). Returns the body astext/markdown; charset=utf-8.
The fetcher uses httpx.AsyncClient with a 5s timeout and no auth
header (the gitea repo is public). The cache is plain
dict keyed by URL-path + time.monotonic() checks; the
_lock is a threading.Lock so the cache is safe under
FastAPI's worker thread pool.
Env knobs:
OHM_SESSION_HISTORY_RAW_BASE(default points atwiggleverse/ohm-session-historyon git.wiggleverse.org)OHM_SESSION_HISTORY_CONTENTS_BASE(default mirrors the raw base on the contents-API mount)OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC(default 60)OHM_DOCS_SESSIONS_CONTENT_TTL_SEC(default 300)
Arc 5 — Backend tests + the monkeypatch ordering snag
Wrote 18 new tests in
backend/tests/test_docs_sessions_vertical.py. The tricky bit
took a couple iterations to nail:
The shared app_with_fake_gitea fixture from
test_propose_vertical.py monkeypatches httpx.AsyncClient via
monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched).
Since app.gitea.httpx IS the shared httpx module, that
setattr mutates the global httpx.AsyncClient attribute. My
docs-sessions test fixture then did the same for
"app.docs_sessions.httpx.AsyncClient" — meant to override —
but the first version captured
real_client_cls = httpx.AsyncClient at fixture-setup time.
At that point httpx.AsyncClient was already the gitea-patched
wrapper, so my "patched" function ended up wrapping the
gitea-patched wrapper, and gitea-bound requests still routed
through the FakeGitea handler (and the FakeGitea returned
404 for session-history URLs, making my test see "manifest
empty").
Fix: import the truly-unpatched AsyncClient from
httpx._client and use that as the base inside my install
closure. The _UpstreamHandler also gained a host-marker
filter so it only counts session-history URLs (not the
reconciler's incidental Gitea calls that also hit our handler
via the shared httpx module) in its .calls list — that lets
the cache-hit assertions stay exact.
Test inventory after the fix lands:
- Manifest happy-path / empty-state / 502
- About happy-path / 404 / 502
- Transcript happy-path / 404 / 502
- Path-validation rejection: invalid nnnn, path-traversal, legacy flat-root filename — all rejected 400 before network
- Session-index happy-path / 404 / invalid-nnnn rejection
- Cache-hit-within-TTL for manifest + transcript
- Negative cache for transcript 404s
All 18 pass; full backend suite is 313/313.
Arc 6 — Frontend slice
The dispatch prompt was specific about reusing the existing
markdown renderer (MarkdownPreview — marked + mermaid lazy-load),
so the four new sub-route components each render their body
through that same component. No new markdown library.
Component layout:
DocsLayout.jsx— shell with the flyout nav +<Outlet/>for sub-routes. Owns the manifest fetch + the mobile-drawer toggle.DocsUserGuide.jsx— DOCS.md content (the old/docsroute).DocsSessionsAbout.jsx— renders the session-history README.DocsSessionIndex.jsx— per-session transcript list. Fetches the manifest (for the title) AND the per-session index endpoint (for the file list). Treats title-fetch failure as decorative (just renders the bareSession NNNNheader) while the file-list fetch is the load-bearing one.DocsSessionTranscript.jsx— renders a single transcript.
App.jsx route registration replaced the single
Route path="/docs" with:
/docs → Navigate to /docs/user-guide
/docs/* → DocsWithSidebar (nested Routes inside)
├── /user-guide
├── /sessions → Navigate to /sessions/about
├── /sessions/about
├── /sessions/:nnnn
└── /sessions/:nnnn/:filename
Mobile drawer: at viewport ≤ 720px the sidebar is hidden by a
transform: translateX(-100%) and revealed via a ☰ button in
the header. A scrim overlay (.docs-drawer-scrim) intercepts
outside clicks. The drawer auto-closes on every route change
(via a useEffect keyed on location.pathname) so a click in
the flyout doesn't strand the user on a drawer-open view.
Analytics (per SPEC §21):
- Added
EVENTS.DOC_VIEWEDtofrontend/src/lib/analytics.js(new constant, taxonomy entry per §21.1's "new events SHOULD land via a release"). Each sub-route component firestrack(EVENTS.DOC_VIEWED, { section: '...' })on mount. - Every interactive nav element (links, buttons) carries
aria-label+data-amp-track-nameso autocapture rows are readable per §21.3. The per-row session links also carrydata-amp-track-session={nnnn}so the dashboard can aggregate by session.
The old Docs.jsx is now dead code; deleted in the same commit.
Arc 7 — Version bumps + CHANGELOG + push
Bumped VERSION (root) and frontend/package.json#version from
0.18.0 → 0.19.0. The dispatch prompt named pyproject.toml
but there isn't one in this repo (the backend uses a plain
requirements.txt); noted in this transcript but no edit
performed.
frontend/package-lock.json still reads 0.15.0 — it has been
drifting since the v0.15.0 release (four minor versions). Not
touched in this release (separate cleanup; see §19.2 candidates).
CHANGELOG entry was prepended above the v0.18.0 entry verbatim per the dispatch prompt (header date, body paragraph, upgrade- steps MAY block, degradation note about the parallel session-history restructure shipping in subsession 0017.2).
Three commits on the feature branch, pushed to origin only:
39e5770v0.19.0 backend: /api/docs/sessions/* endpoints + TTL cache822f426v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser477f496Release v0.19.0: /docs nav + on-site sessions browser
benstull mirror untouched per dispatch.
Cut state
| rfc-app | feature/v0.19.0-docs-sessions-browser @ 477f496, pushed to origin only |
| Tests | pytest backend: 313 passing (18 new in test_docs_sessions_vertical.py); frontend has no test framework — npm run build is green |
| Frontend dev-server smoke check | Skipped (subsession is a sandboxed agent, no browser to drive). The CHANGELOG-named degradation path is structurally exercised by the backend's 404 → frontend's empty-state contract; route-mount errors would have surfaced at npm run build |
| What the driver does next | tag v0.19.0 after 0017.2 lands → merge to main → push to origin + benstull mirror → bump ohm-rfc pin → flotilla deploy ohm-rfc-app → verify /docs/user-guide + /docs/sessions/about surfaces in the deployed environment |
§19.2 candidates surfaced
-
frontend/package-lock.jsonversion drift — what / lock reads0.15.0whilepackage.jsonis now0.19.0(the lock has been stale since the v0.15.0 release). why / it's an audit-log inconsistency in the npm metadata. when / one-shot cleanup, low-priority — the lock doesn't affect builds because the resolved versions are correct, just the top-levelnamestanza. Fix in the next session that touches frontend deps. -
rfc-app backend has no
pyproject.toml— what / the dispatch prompt assumed apyproject.tomlexists; the repo uses a plainbackend/requirements.txt. why / standard Python tooling (uv, hatch, ruff config, mypy config) all prefer pyproject.toml; absence makes those tools less ergonomic. when / a session that brings in another tool that wants the file. Could pair with a §20.1-style canonical-source audit ("VERSION here, pyproject.toml there — which wins?"). -
docs_sessions.py's private symbols leak intoapi.py— what / I calleddocs_sessions._is_valid_session_diranddocs_sessions._is_valid_transcript_filenamefrom the route layer (underscore-prefixed names). why / the validation is load-bearing for path-traversal rejection and shouldn't be re-implemented in the route, but exporting two helpers vs. one consolidatedvalidate_path(nnnn, filename) -> boolwould keep the API cleaner. when / next docs-sessions touch. -
CHANGELOG entry header style drifted — what / the v0.18.0 entry header is
## 0.18.0 — 2026-05-28(novprefix); my new v0.19.0 entry is## v0.19.0 — 2026-05-28(withvprefix) — I followed the dispatch prompt's verbatim block. why / cosmetic inconsistency in the changelog history. when / a release that audits the file for consistency could rename one direction or the other. -
docs_sessionscache cannot be invalidated externally — what / the in-process cache exposesreset_cache()but no "invalidate this URL" gesture. why / if the operator publishes a transcript correction and the cache has the old body, they wait up to 5 minutes for the next reader to see the fix. when / a session that wires the reconciler's existing periodic refresh gesture incache.pyto also tick this module — or that adds an admin POST/api/docs/sessions/refreshfor an explicit bust. Low priority; the publish gesture is rare.
Notes for the driver
-
Path-traversal corner: FastAPI / Starlette normalizes
..in the URL path before routing, so/api/docs/sessions/0001/../etc/passwdresolves to a path that doesn't match the/api/docs/sessions/{nnnn}/{filename}route. The defense-in-depth is still in place (regex validatesnnnnANDfilenamebefore any network call), but the test for path traversal had to useetc%2Fpasswd(URL-encoded slash) to actually reach the handler — the bare../etc/passwdwas 404'd by the router itself. -
TestClient + lifespan: the backend tests use
with TestClient(app) as client:which triggers the FastAPI lifespan. That starts the reconciler, which makes Gitea calls that ALSO hit our patchedhttpx.AsyncClient(since httpx is a shared module — see Arc 5). The_UpstreamHandlerfilters its.callslist to session-history URLs to keep the cache-hit assertions exact. Reconciler hits return 404 (no matching key in the responses dict), which the reconciler logs as a warning and proceeds — harmless for our test surface. -
Frontend dev-server check not run: the dispatch prompt named this as optional. The subsession is a sandboxed agent without a browser to drive; the build's success and the route registration's symmetry with the existing
/philosophychrome is the closest analog I could verify. Recommend the driver runnpm run devagainst the feature branch before merging to spot-check the flyout's render + drawer toggle on a real viewport. -
No tag, no pin, no mirror push — per dispatch. Driver handles tag → ohm-rfc pin bump → flotilla deploy after 0017.2 lands.