add 0017/SESSION-0017.1-TRANSCRIPT-2026-05-28T08-52--2026-05-28T09-07.md
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
# 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-browser`
|
||||
> at `477f496`. 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 `/docs` into 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-app` was at `main @ ac3513a`, VERSION `0.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 `main` was v0.18.0 (email + webhook
|
||||
hygiene, roadmap items #18 + #20).
|
||||
- Existing `/docs` surface was the v0.14.0 single-route DOCS.md
|
||||
shape: `components/Docs.jsx` rendering markdown via the shared
|
||||
`MarkdownPreview` (marked + lazy-loaded mermaid). Backend served
|
||||
the body at `/api/docs` from a disk-cached `DOCS.md` at 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:
|
||||
|
||||
1. Extend the manifest endpoint to also list per-session filenames
|
||||
via the gitea contents API.
|
||||
2. 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` — returns `sessions.json` as 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 as
|
||||
`text/markdown; charset=utf-8`. 404 → HTTP 404; 5xx → 502.
|
||||
- `GET /api/docs/sessions/{nnnn}/index` — returns
|
||||
`{"files": [...]}`. The path param `nnnn` must 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 as `text/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 at
|
||||
`wiggleverse/ohm-session-history` on 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 `/docs` route).
|
||||
- `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 bare `Session NNNN` header) 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_VIEWED` to `frontend/src/lib/analytics.js`
|
||||
(new constant, taxonomy entry per §21.1's "new events SHOULD
|
||||
land via a release"). Each sub-route component fires
|
||||
`track(EVENTS.DOC_VIEWED, { section: '...' })` on mount.
|
||||
- Every interactive nav element (links, buttons) carries
|
||||
`aria-label` + `data-amp-track-name` so autocapture rows are
|
||||
readable per §21.3. The per-row session links also carry
|
||||
`data-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:
|
||||
- `39e5770` v0.19.0 backend: /api/docs/sessions/* endpoints + TTL cache
|
||||
- `822f426` v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser
|
||||
- `477f496` Release 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
|
||||
|
||||
1. **`frontend/package-lock.json` version drift** — what / lock
|
||||
reads `0.15.0` while `package.json` is now `0.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-level `name`
|
||||
stanza. Fix in the next session that touches frontend deps.
|
||||
|
||||
2. **rfc-app backend has no `pyproject.toml`** — what / the
|
||||
dispatch prompt assumed a `pyproject.toml` exists; the repo
|
||||
uses a plain `backend/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?").
|
||||
|
||||
3. **`docs_sessions.py`'s private symbols leak into `api.py`** —
|
||||
what / I called `docs_sessions._is_valid_session_dir` and
|
||||
`docs_sessions._is_valid_transcript_filename` from 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 consolidated `validate_path(nnnn, filename) -> bool` would
|
||||
keep the API cleaner. when / next docs-sessions touch.
|
||||
|
||||
4. **CHANGELOG entry header style drifted** — what / the v0.18.0
|
||||
entry header is `## 0.18.0 — 2026-05-28` (no `v` prefix); my
|
||||
new v0.19.0 entry is `## v0.19.0 — 2026-05-28` (with `v`
|
||||
prefix) — 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.
|
||||
|
||||
5. **`docs_sessions` cache cannot be invalidated externally** —
|
||||
what / the in-process cache exposes `reset_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 in `cache.py` to also tick this module — or that adds
|
||||
an admin POST `/api/docs/sessions/refresh` for 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/passwd`
|
||||
resolves to a path that doesn't match the
|
||||
`/api/docs/sessions/{nnnn}/{filename}` route. The defense-in-depth
|
||||
is still in place (regex validates `nnnn` AND `filename` before
|
||||
any network call), but the test for path traversal had to use
|
||||
`etc%2Fpasswd` (URL-encoded slash) to actually reach the
|
||||
handler — the bare `../etc/passwd` was 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 patched `httpx.AsyncClient` (since httpx is a
|
||||
shared module — see Arc 5). The `_UpstreamHandler` filters its
|
||||
`.calls` list 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 `/philosophy` chrome
|
||||
is the closest analog I could verify. Recommend the driver run
|
||||
`npm run dev` against 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.
|
||||
Reference in New Issue
Block a user