Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc9949972 | |||
| e0d9ed7c5a | |||
| 69a166a6f2 | |||
| bb5137f176 | |||
| 477f496cbf | |||
| 822f4266f6 | |||
| 39e57706d9 | |||
| ac3513a686 | |||
| 213f6862d5 |
@@ -23,6 +23,36 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.20.0 — 2026-05-28
|
||||||
|
|
||||||
|
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
||||||
|
|
||||||
|
1. **Specs on `/docs/specs/<name>`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list).
|
||||||
|
|
||||||
|
2. **Nested flyout nav hierarchy** — `/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint.
|
||||||
|
|
||||||
|
3. **`/docs/sessions/<NNNN>` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.
|
||||||
|
|
||||||
|
Upgrade steps:
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL.
|
||||||
|
|
||||||
|
Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move.
|
||||||
|
|
||||||
|
## 0.19.0 — 2026-05-28
|
||||||
|
|
||||||
|
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.
|
||||||
|
|
||||||
|
Upgrade steps:
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url>` if the deployment points at a non-OHM transcript repo. Default in code matches OHM's `wiggleverse/ohm-session-history`.
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60` and `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300` to tune cache TTLs.
|
||||||
|
|
||||||
|
Note: this release depends on the parallel restructure of `wiggleverse/ohm-session-history` into per-session `NNNN/` folders + `README.md` + `sessions.json` (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, `/docs/sessions/about` and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.
|
||||||
|
|
||||||
## 0.18.0 — 2026-05-28
|
## 0.18.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; one env var now mandatory; no
|
**Minor — schema migration required; one env var now mandatory; no
|
||||||
|
|||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
# Contributing to rfc-app
|
||||||
|
|
||||||
|
`rfc-app` is the framework that hosts RFC-shaped collections of
|
||||||
|
documents — one repo per RFC, a meta repo per collection, a web app
|
||||||
|
that turns the Git substrate into a writeable surface. The Open
|
||||||
|
Human Model (OHM) deployment at `ohm.wiggleverse.org` is one
|
||||||
|
instance. The framework is intended to host more.
|
||||||
|
|
||||||
|
This document explains how to propose a change to the framework
|
||||||
|
itself — a new endpoint, a schema migration, a UI affordance, a
|
||||||
|
spec clarification. For changes to *content* hosted by a specific
|
||||||
|
deployment (the OHM RFCs, the OHM roadmap), see that deployment's
|
||||||
|
own contribution guide (e.g. [`ohm-rfc/CONTRIBUTING.md`](https://git.wiggleverse.org/wiggleverse/ohm-rfc/src/branch/main/CONTRIBUTING.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the project actually evolves
|
||||||
|
|
||||||
|
rfc-app is built in the open in the literal sense: **every build
|
||||||
|
session produces a full transcript** at
|
||||||
|
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
|
||||||
|
on `git.wiggleverse.org`. The transcripts are the authoritative
|
||||||
|
record of how the framework got from one release to the next — the
|
||||||
|
decisions, the friction, the dead ends, the reasoning. They are not
|
||||||
|
curated retrospectives; wrong turns stay in.
|
||||||
|
|
||||||
|
If you are proposing a change to rfc-app, **read at least the most
|
||||||
|
recent session transcript before opening a PR.** The transcripts
|
||||||
|
show what shape a feature lands in, where the spec gets touched,
|
||||||
|
what the operator pushes back on, and how the release rides into
|
||||||
|
deployment. A PR that matches that texture is much more likely to
|
||||||
|
land cleanly than one shaped by the README alone.
|
||||||
|
|
||||||
|
Worked examples to start with:
|
||||||
|
|
||||||
|
- **Session E** ([transcript](https://git.wiggleverse.org/wiggleverse/ohm-session-history)) —
|
||||||
|
a clean small release. Read this for the simplest possible release
|
||||||
|
shape: one feature, one version bump, one upgrade-steps block, no
|
||||||
|
surprises.
|
||||||
|
- **Session I** — recovery from a deploy fault. Read this for how
|
||||||
|
the project handles things going wrong mid-deploy, and for the
|
||||||
|
honest no-curation discipline.
|
||||||
|
- **Session K** — a multi-feature wave with one item paused on an
|
||||||
|
operator-provided secret. Read this for the subagent dispatch
|
||||||
|
pattern (the model the project uses to ship multiple features in
|
||||||
|
parallel), and for the binding rule that the assistant **never**
|
||||||
|
asks the operator to paste secret bytes into the conversation.
|
||||||
|
- **Session L** — squash-merge integration across three parallel
|
||||||
|
features (v0.15.0 / v0.16.0 / v0.17.0), with `#21 Part C`
|
||||||
|
identity-lifecycle Amplitude wiring folded inline across all
|
||||||
|
three releases. Read this for how cross-cutting concerns (analytics,
|
||||||
|
observability) get layered into already-in-flight features
|
||||||
|
without scope-creeping any single release.
|
||||||
|
|
||||||
|
The repository where transcripts live —
|
||||||
|
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history) —
|
||||||
|
is the canonical history. The `git log` of `rfc-app` is the artifact;
|
||||||
|
the transcripts are the story behind it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How a contribution flows
|
||||||
|
|
||||||
|
The framework runs on a **subagents push feature branches; operator
|
||||||
|
tags and deploys** model. Contributors — whether human or AI agents
|
||||||
|
running in a Claude Code subsession — open feature branches and
|
||||||
|
submit PRs. The operator (the person running the deployment) is the
|
||||||
|
one who merges, tags, bumps `VERSION`, runs `flotilla deploy` (or
|
||||||
|
the equivalent for non-OHM deployments), and moves the deployment's
|
||||||
|
`.rfc-app-version` pin. The driver session transcripts inherit
|
||||||
|
this shape; contributors inherit it from them.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
1. Read the most recent session transcript. Understand what just
|
||||||
|
shipped and what is in flight.
|
||||||
|
2. Open an Issue first if your change is exploratory, structural,
|
||||||
|
or might overlap with in-flight work. The operator will name
|
||||||
|
any collision.
|
||||||
|
3. Branch from `main`. Name the branch
|
||||||
|
`feature/<short-description>` for additive work, `fix/<short-
|
||||||
|
description>` for bug fixes, `docs/<short-description>` for
|
||||||
|
documentation-only work. The driver sessions use
|
||||||
|
`feature/v<target-version>-<slug>` (e.g.
|
||||||
|
`feature/v0.16.0-owner-invite`) — that shape is welcome but not
|
||||||
|
required for outside contributors, since contributors do not
|
||||||
|
pick the target version.
|
||||||
|
4. **Do not bump `VERSION` or `frontend/package.json#version` in
|
||||||
|
your PR.** The operator picks the target version at integration
|
||||||
|
time; bumping ahead causes cherry-pick conflicts. The same
|
||||||
|
applies to the `CHANGELOG.md` entry header — see below.
|
||||||
|
5. **Do not tag releases, do not run any deploy gesture, do not
|
||||||
|
touch any deployment's `.rfc-app-version` pin.** The operator
|
||||||
|
alone owns those gestures. (For OHM specifically: "I'm the only
|
||||||
|
one that gets to yolo." See the boundary section in
|
||||||
|
`ohm-rfc/CONTRIBUTING.md`.)
|
||||||
|
6. Push your branch and open a PR. Describe what you're proposing
|
||||||
|
and why, in language the operator can paste into the eventual
|
||||||
|
release commit. If the change touches `SPEC.md`, name which
|
||||||
|
section(s) and the contract change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CHANGELOG convention: strict descending
|
||||||
|
|
||||||
|
`CHANGELOG.md` is ordered **newest-on-top**. The header line for
|
||||||
|
the in-progress version goes at the top of the file; older
|
||||||
|
releases descend below it. This is the binding convention; the
|
||||||
|
operator hand-resolves the conflict when two parallel feature
|
||||||
|
branches both insert at the top of the file (the squash-merge
|
||||||
|
integration that ships parallel-feature waves keeps the strict-
|
||||||
|
descending shape — see Session K for the cherry-pick mechanics and
|
||||||
|
Session L for the hand-resolved-with-a-small-script variant).
|
||||||
|
|
||||||
|
A new entry has this shape (read the existing 0.15.0 / 0.16.0 /
|
||||||
|
0.17.0 entries for worked examples):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 0.X.Y — YYYY-MM-DD
|
||||||
|
|
||||||
|
**Minor — schema migration auto-applied; no operator action.** This
|
||||||
|
release ships <one or two sentences naming the feature and why>.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **<New module/endpoint/component>** — what it does, where it lives,
|
||||||
|
why it exists. Include file paths inline so a reader can click through.
|
||||||
|
### Changed
|
||||||
|
- **<Existing surface>** — what changed and how a deployment notices.
|
||||||
|
### Migration
|
||||||
|
- **`<NNN_name>.sql`** — auto-applied by `db.run_migrations()` on
|
||||||
|
backend start. <Describe the schema delta in one sentence.>
|
||||||
|
### Upgrade steps (from 0.(X-1).Y)
|
||||||
|
- You **MUST** … (per RFC 2119; see SPEC.md §20.4).
|
||||||
|
- You **MUST NOT** …
|
||||||
|
- You **SHOULD** …
|
||||||
|
- You **MAY** …
|
||||||
|
```
|
||||||
|
|
||||||
|
The header version number is filled in by the operator at merge
|
||||||
|
time. Your PR's CHANGELOG diff can leave the version as
|
||||||
|
`0.X.Y — YYYY-MM-DD` (literal placeholder), or use a guessed value
|
||||||
|
the operator overwrites; either is fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Upgrade steps:` blocks use RFC 2119 keywords
|
||||||
|
|
||||||
|
If your change requires deployments to do anything when they
|
||||||
|
upgrade — set an env var, apply a migration, restart a process,
|
||||||
|
flip an overlay value, accept a behavioral change — your CHANGELOG
|
||||||
|
entry **must** include an `### Upgrade steps` block, and that
|
||||||
|
block **must** use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
||||||
|
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) keywords as
|
||||||
|
defined in `SPEC.md` §20.4:
|
||||||
|
|
||||||
|
- **MUST** / **SHALL** / **REQUIRED** — without this step the
|
||||||
|
deployment will not function correctly. Skipping is a regression
|
||||||
|
the framework does not handle.
|
||||||
|
- **MUST NOT** / **SHALL NOT** — previously valid, now no longer
|
||||||
|
supported.
|
||||||
|
- **SHOULD** / **RECOMMENDED** — the framework's tested path. A
|
||||||
|
deployment may deviate when it has a reason.
|
||||||
|
- **SHOULD NOT** / **NOT RECOMMENDED** — discouraged without being
|
||||||
|
forbidden.
|
||||||
|
- **MAY** / **OPTIONAL** — an affordance you can take or skip.
|
||||||
|
|
||||||
|
Cross-version upgrades (jumping more than one minor) are computed by
|
||||||
|
the operator composing each intervening release's steps in order.
|
||||||
|
Each adjacent step must therefore be locally unambiguous — this is
|
||||||
|
the whole reason the keyword discipline is binding. Avoid words
|
||||||
|
like "should probably" or "might want to" inside an upgrade step;
|
||||||
|
either the framework needs the action or it doesn't.
|
||||||
|
|
||||||
|
If your change touches the env contract, **also update**
|
||||||
|
`backend/.env.example` and/or `frontend/.env.example` in the same
|
||||||
|
PR so the contract and the documentation land together (§20.4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPEC.md and §19.2 candidates
|
||||||
|
|
||||||
|
`SPEC.md` is the framework's binding spec. It is honest about open
|
||||||
|
questions — large sections of it carry "§19.2 candidates," which
|
||||||
|
are decisions the project has deliberately deferred rather than
|
||||||
|
guessed at.
|
||||||
|
|
||||||
|
The discipline: **architectural or process deferrals get noted as
|
||||||
|
§19.2 candidates rather than scope-creeping a release.** When you
|
||||||
|
notice that your change opens a question larger than the change
|
||||||
|
itself (a different DB shape, a new auth contract, a cross-cutting
|
||||||
|
UX rethink), the right move is usually to land the narrow change
|
||||||
|
and add a §19.2 candidate naming the larger question. The candidate
|
||||||
|
documents what was set aside and why, so a future session can pick
|
||||||
|
it up with context.
|
||||||
|
|
||||||
|
Worked examples from recent sessions:
|
||||||
|
|
||||||
|
- v0.11.0 (Session K) shipped device trust and surfaced three new
|
||||||
|
§19.2 candidates: cross-device session revocation, password-
|
||||||
|
equivalent change invalidating trust, device-trust window
|
||||||
|
tunables via env. None of those were in the v0.11.0 scope; they
|
||||||
|
were noted in SPEC.md §19.2 so a future session can address them
|
||||||
|
on their own terms.
|
||||||
|
- v0.15.0 (Session L) shipped the Amplitude wrapper and added
|
||||||
|
candidates around session-replay-specific consent category +
|
||||||
|
bundle-size measurement, both deferred to the future Part-A audit.
|
||||||
|
|
||||||
|
When you spot a deferred decision in your PR's territory, name it
|
||||||
|
in your PR description and add it to `SPEC.md` §19.2 in the same
|
||||||
|
diff. Do not silently expand scope to settle it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test-coverage expectations
|
||||||
|
|
||||||
|
The backend has the load-bearing test suite at
|
||||||
|
`backend/tests/`. Tests are organized as `*_vertical.py` files,
|
||||||
|
each covering one feature end-to-end through the FastAPI app
|
||||||
|
(provisioning fixtures, hitting the HTTP surface, asserting on the
|
||||||
|
database state). At time of writing, the suite is ~250 tests across
|
||||||
|
~25 files. Examples:
|
||||||
|
|
||||||
|
- `test_admin_create_user_invite_vertical.py` — v0.17.0's
|
||||||
|
admin-create user + invite + claim flow, 15 tests covering happy
|
||||||
|
path + every refusal shape + the audit-trail row.
|
||||||
|
- `test_rfc_invitations_vertical.py` — v0.16.0's per-RFC invite +
|
||||||
|
accept flow, 18 tests.
|
||||||
|
- `test_device_trust_vertical.py` — v0.11.0's 30-day device trust,
|
||||||
|
14 tests including cookie shape, hash-vs-raw-token discipline,
|
||||||
|
expired / revoked / forged / cross-user invariants.
|
||||||
|
|
||||||
|
Expected coverage for a new feature:
|
||||||
|
|
||||||
|
- **Backend feature** — one new `test_<feature>_vertical.py` file
|
||||||
|
that covers the happy path, every documented refusal/error code,
|
||||||
|
and any cross-surface effect (rows the feature writes to existing
|
||||||
|
tables, fields it adds to existing endpoints). Reuse fixtures
|
||||||
|
from neighboring test files (e.g. `test_propose_vertical.py`'s
|
||||||
|
`FakeGitea` is widely reused).
|
||||||
|
- **Migration** — verify migrations are reachable from `backend/.venv`
|
||||||
|
before pushing: `cd backend && PYTHONPATH=. .venv/bin/pytest -q`
|
||||||
|
exercises `db.run_migrations()` through the fixture setup.
|
||||||
|
- **Frontend feature** — there is currently no frontend test
|
||||||
|
runner. The discipline is: keep the change ships-clean
|
||||||
|
(`cd frontend && npm run build` succeeds), and the backend
|
||||||
|
vertical test exercises the HTTP contract the frontend
|
||||||
|
consumes, which is the meaningful behavioral guarantee.
|
||||||
|
Frontend changes that ride along with a backend feature land
|
||||||
|
with the backend test as the regression boundary.
|
||||||
|
- **Bug fix** — add a regression test in the same vertical file
|
||||||
|
that proves the original failure mode and verifies the fix.
|
||||||
|
|
||||||
|
Run the backend suite before pushing. From `backend/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. .venv/bin/pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `PYTHONPATH=.` is a known ergonomic gap — see SPEC.md §19.2
|
||||||
|
candidate; the suite does not pick up `app/` without it.)
|
||||||
|
|
||||||
|
If your PR doesn't include tests, the operator will ask for them
|
||||||
|
before merge unless the change is genuinely test-irrelevant
|
||||||
|
(documentation, comments, dev-only tooling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analytics instrumentation checklist
|
||||||
|
|
||||||
|
> *(This section codifies `ohm-rfc/ROADMAP.md` #21 Part B's
|
||||||
|
> CONTRIBUTING checklist. It is discipline, not a gate — but the
|
||||||
|
> operator will push back on PRs that skip it.)*
|
||||||
|
|
||||||
|
If your PR adds or changes a user-facing feature, walk this
|
||||||
|
checklist before opening the PR. The instrumentation conventions
|
||||||
|
themselves are specified in `SPEC.md` §21 (Analytics instrumentation
|
||||||
|
and identity); this section is the procedural reminder.
|
||||||
|
|
||||||
|
1. **What named event(s) does this feature need?**
|
||||||
|
Open `frontend/src/lib/analytics.js` and look at the `EVENTS`
|
||||||
|
constant. Does an existing event cover your feature? If not, is
|
||||||
|
the new event in the spec's "Subject Verb" Title Case form
|
||||||
|
(`Comment Posted`, `Invitation Sent`)? Are the prop families
|
||||||
|
consistent with SPEC.md §21's required-prop catalog (opaque
|
||||||
|
ids only, no PII, enums lowercased like `'otc'` not `'OTC'`)?
|
||||||
|
|
||||||
|
2. **Do interactive elements have stable text / ARIA labels /
|
||||||
|
`data-amp-track-*` so autocapture is meaningful?**
|
||||||
|
The frontend ships `autocapture: true`, which instruments
|
||||||
|
every click and form interaction. The *value* of those events
|
||||||
|
depends on the DOM the SDK sees: a `<button>` with stable
|
||||||
|
visible text or an `aria-label` shows up as a meaningful
|
||||||
|
dashboard row; an icon-only `<button>` with no label shows up
|
||||||
|
as garbage. New components that introduce interactive elements
|
||||||
|
should either carry meaningful labels (visible text or ARIA) or
|
||||||
|
carry a `data-amp-track-name="<Stable Name>"` attribute. For
|
||||||
|
repeated rows (per-RFC lists, comment lists), use a stable
|
||||||
|
`data-amp-track-*` identifier so per-row click counts aggregate
|
||||||
|
to the row's identity rather than to a generic label.
|
||||||
|
|
||||||
|
3. **Does any new form field need replay masking?**
|
||||||
|
Session replay records at `sampleRate: 1` (100% of consented
|
||||||
|
sessions). New form inputs that capture passwords, OTC codes,
|
||||||
|
tokens, magic-link URLs, or other secret/credential-equivalent
|
||||||
|
material **MUST** be masked with Amplitude's masking conventions
|
||||||
|
(the `.amp-mask` class or the `data-amp-mask` attribute,
|
||||||
|
whichever the wrapper integration expects in this version).
|
||||||
|
New inputs that capture arguably-PII (email, real name, free-
|
||||||
|
text drafts) **SHOULD** also be masked; if a deliberate
|
||||||
|
un-masking decision is taken, document it in the PR description
|
||||||
|
and in `SPEC.md` §21.
|
||||||
|
|
||||||
|
4. **Does the PR description name the instrumentation decisions?**
|
||||||
|
A one-sentence summary in the PR description — "fires
|
||||||
|
`Comment Posted` with `{rfc_slug, comment_id}`; no new form
|
||||||
|
fields, no new replay-masking concerns" — is enough. If the
|
||||||
|
decision is "we chose not to instrument this," say that too;
|
||||||
|
the absence of an event is itself a decision the operator
|
||||||
|
wants visible. The relevant SPEC chapter (§21) is the binding
|
||||||
|
reference for what shapes are correct.
|
||||||
|
|
||||||
|
If your feature touches an identity-meaningful surface (sign-in,
|
||||||
|
sign-out, invite-claim, role change, account state change), also
|
||||||
|
walk the **identity lifecycle** contract in SPEC.md §21.6: every
|
||||||
|
new claim/sign-in path **MUST** call `identify({ user_id, properties })`
|
||||||
|
BEFORE the first `track()` event on that surface, so the Amplitude
|
||||||
|
user record is created with the OHM user_id from the very first
|
||||||
|
event rather than as an anonymous device that retroactively links.
|
||||||
|
v0.16.0's `AcceptInvitation.jsx` and v0.17.0's `InviteClaim.jsx`
|
||||||
|
are the worked examples; mirror their shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The operator-only gestures
|
||||||
|
|
||||||
|
Some gestures are operator-only. Contributors do not perform them;
|
||||||
|
PRs that perform them get rejected on principle, not on merit:
|
||||||
|
|
||||||
|
- **Tagging a release** (`git tag v0.X.Y` + `git push --tags`).
|
||||||
|
- **Pushing to `main`** after merge (the operator merges; the
|
||||||
|
framework's `main` branch tracks releases the operator has
|
||||||
|
shipped).
|
||||||
|
- **Bumping `VERSION` and `frontend/package.json#version` to the
|
||||||
|
shipped value.** The operator does this at integration time so
|
||||||
|
the version line is consistent across the release commit.
|
||||||
|
- **Running `flotilla deploy` or any equivalent deployment gesture**
|
||||||
|
in any deployment of rfc-app. Contributors do not deploy.
|
||||||
|
- **Moving a deployment's `.rfc-app-version` pin.** That pin lives
|
||||||
|
in the deployment's content repo (e.g. `ohm-rfc/.rfc-app-version`)
|
||||||
|
and is moved by the deployment's operator. Contributors to that
|
||||||
|
deployment do not move it; contributors to the framework
|
||||||
|
certainly do not.
|
||||||
|
- **Setting secrets** (anywhere — Secret Manager, env files,
|
||||||
|
`flotilla secret set`, vendor dashboards, anything). The
|
||||||
|
binding rule baked in mid-Session-K is: **the assistant never
|
||||||
|
asks the operator to paste secret bytes into a conversation,
|
||||||
|
even as one offered option**. The corollary for contributors:
|
||||||
|
do not include secret values in PR descriptions, commit
|
||||||
|
messages, or issue comments. Reference secrets by their binding
|
||||||
|
name (`SMTP_PASSWORD`, `AMPLITUDE_API_KEY`) and let the
|
||||||
|
operator handle the bytes.
|
||||||
|
|
||||||
|
If your change requires a new secret or env var, document the
|
||||||
|
requirement in the CHANGELOG `### Upgrade steps` block in the
|
||||||
|
RFC 2119 form ("operators **MUST** set `<NEW_VAR>` ...") and
|
||||||
|
update the `*.env.example` file. The operator will run the
|
||||||
|
secret/overlay-set gesture themselves at deploy time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When in doubt
|
||||||
|
|
||||||
|
- **Open an Issue first.** Especially for any change that touches
|
||||||
|
SPEC.md, the auth/permissions model (§6), the storage shape (§4
|
||||||
|
/ §5), or the deploy contract (§20). The operator (or a future
|
||||||
|
driver session) will name what they want before you write code.
|
||||||
|
- **Read the most recent session transcript.** It will tell you
|
||||||
|
what shipped last and what's in flight.
|
||||||
|
- **Cite SPEC.md sections in your PR description.** "Touches §15.4
|
||||||
|
(per-category email toggles) and adds §19.2 candidate around
|
||||||
|
per-channel mute granularity" gives the operator a map of where
|
||||||
|
to read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The framework is released under the MIT License (see
|
||||||
|
[`LICENSE`](./LICENSE)). By contributing, you agree your work
|
||||||
|
ships under those terms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`SPEC.md`](./SPEC.md) — the framework's binding spec. §19.2
|
||||||
|
is the deferred-decisions queue; §20 is the versioning + deploy
|
||||||
|
contract; §21 is the analytics instrumentation contract.
|
||||||
|
- [`CHANGELOG.md`](./CHANGELOG.md) — release history in strict
|
||||||
|
descending order. Read recent entries for the shape your PR's
|
||||||
|
release-commit will take.
|
||||||
|
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — what the framework is for.
|
||||||
|
PRs whose shape conflicts with the philosophy get a longer
|
||||||
|
conversation than PRs that fit.
|
||||||
|
- [`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
|
||||||
|
— the authoritative record of how the project has actually
|
||||||
|
evolved, session by session.
|
||||||
@@ -2284,6 +2284,13 @@ a given signal, the **storage shape** that makes triage tractable, and
|
|||||||
the **out-of-session channels** (email, digest) that let asynchrony
|
the **out-of-session channels** (email, digest) that let asynchrony
|
||||||
actually work.
|
actually work.
|
||||||
|
|
||||||
|
(The framework's separate **analytics + session-replay** surface —
|
||||||
|
Amplitude wiring, event taxonomy, identity lifecycle, consent
|
||||||
|
contract — is a peer cross-cutting concern specified in §21.
|
||||||
|
Notifications cover in-product signal-of-others-acting-on-your-work;
|
||||||
|
analytics covers observability of how the product is used. The two
|
||||||
|
surfaces do not overlap.)
|
||||||
|
|
||||||
### 15.1 The signal-surface stack
|
### 15.1 The signal-surface stack
|
||||||
|
|
||||||
Five surfaces, each with one narrow job:
|
Five surfaces, each with one narrow job:
|
||||||
@@ -4300,3 +4307,465 @@ Downstream deployments, in exchange for the contract above, commit to:
|
|||||||
order;
|
order;
|
||||||
- supply every required env var the framework documents at the
|
- supply every required env var the framework documents at the
|
||||||
version they are running.
|
version they are running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Analytics instrumentation and identity
|
||||||
|
|
||||||
|
The framework ships an Amplitude Analytics + Session Replay wrapper
|
||||||
|
in v0.15.0 (`frontend/src/lib/analytics.js`), gated by the v0.13.0
|
||||||
|
cookie/privacy consent surface (`frontend/src/lib/consent.js`,
|
||||||
|
§14.5). This section codifies the conventions that keep the
|
||||||
|
instrumentation **quality** healthy as features land — taxonomy
|
||||||
|
shape, autocapture hygiene, replay masking, the consent contract,
|
||||||
|
and the identity lifecycle. The conventions are framework-neutral:
|
||||||
|
every deployment of rfc-app that turns on the wrapper inherits
|
||||||
|
them.
|
||||||
|
|
||||||
|
This chapter is placed semantically after §15 (Notifications) and
|
||||||
|
§16 (deliberately deferred) as a peer cross-cutting framework
|
||||||
|
concern. It was added after §20 in the chapter sequence to avoid
|
||||||
|
renumbering the deferred-decisions surface §19.2, which is a
|
||||||
|
load-bearing project noun referenced across CLAUDE.md, transcripts,
|
||||||
|
and prior commits.
|
||||||
|
|
||||||
|
### 21.1 Event-taxonomy conventions
|
||||||
|
|
||||||
|
Events live in the public `EVENTS` constant in
|
||||||
|
`frontend/src/lib/analytics.js`. Callers **SHOULD** use one of the
|
||||||
|
named constants rather than firing arbitrary event strings — that
|
||||||
|
keeps the Amplitude dashboard coherent over time and makes the
|
||||||
|
taxonomy reviewable as a single source of truth.
|
||||||
|
|
||||||
|
- **Name form: Title Case, "Subject Verb".** E.g.
|
||||||
|
`Comment Posted`, `Invitation Sent`, `RFC Viewed`,
|
||||||
|
`User Signed In`, `Admin Permission Decision`. Spaces between
|
||||||
|
words, no punctuation, no leading verbs (use `RFC Proposed`,
|
||||||
|
not `Propose RFC`). The strings match the Amplitude dashboard
|
||||||
|
names exactly.
|
||||||
|
- **Stability.** New events **SHOULD** land via a release, not
|
||||||
|
ad-hoc — adding an entry to `EVENTS` is a CHANGELOG-worthy
|
||||||
|
change because it widens the framework's observable surface
|
||||||
|
(§20.3). Renaming an event after it has shipped breaks the
|
||||||
|
dashboard's historical continuity; renames **SHOULD** be
|
||||||
|
treated as a deprecation cycle (ship both, dashboard-migrate,
|
||||||
|
drop the old one).
|
||||||
|
- **Opaque ids only in prop values.** Properties **MUST NOT**
|
||||||
|
carry PII — no email, no display name, no IP, no free-text
|
||||||
|
field bodies (titles, comment text, RFC drafts). Properties
|
||||||
|
**SHOULD** be limited to:
|
||||||
|
- opaque ids: `rfc_slug`, `rfc_id`, `pr_number`,
|
||||||
|
`target_user_id`, `invited_by_admin_id`, `thread_id`,
|
||||||
|
`comment_id`;
|
||||||
|
- enums (lowercased): `method: 'otc' | 'passcode' |
|
||||||
|
'device-trust' | 'admin-invite' | 'rfc-invite'`;
|
||||||
|
- booleans: `trust_device`, `needs_passcode`, `passcode_set`;
|
||||||
|
- timestamps (ISO 8601);
|
||||||
|
- small bounded integers: `custom_message_chars` (coarse-grained
|
||||||
|
signal of admin effort, NOT the message text itself).
|
||||||
|
- **Casing consistency.** Prop keys use `snake_case` (matches the
|
||||||
|
backend's JSON shape). Enum values use lowercase with hyphens
|
||||||
|
(`'rfc-invite'`, not `'rfcInvite'` or `'RFC_INVITE'`). Drift
|
||||||
|
here ruins dashboard aggregation; the operator-side audit
|
||||||
|
(§21.7 / `ohm-rfc/ROADMAP.md` #21 Part A) checks for it.
|
||||||
|
|
||||||
|
The starting taxonomy as of v0.17.0:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAGE_VIEWED: 'Page Viewed'
|
||||||
|
RFC_VIEWED: 'RFC Viewed'
|
||||||
|
USER_SIGNED_IN: 'User Signed In'
|
||||||
|
USER_SIGNED_OUT: 'User Signed Out'
|
||||||
|
RFC_PROPOSED: 'RFC Proposed'
|
||||||
|
PR_OPENED: 'PR Opened'
|
||||||
|
COMMENT_POSTED: 'Comment Posted'
|
||||||
|
BETA_ACCESS_REQUESTED: 'Beta Access Requested'
|
||||||
|
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision'
|
||||||
|
INVITATION_SENT: 'Invitation Sent' # v0.16.0 / #12
|
||||||
|
INVITATION_ACCEPTED: 'Invitation Accepted' # v0.16.0 / #12
|
||||||
|
USER_INVITED: 'User Invited' # v0.17.0 / #16
|
||||||
|
INVITE_CLAIMED: 'Invite Claimed' # v0.17.0 / #16
|
||||||
|
```
|
||||||
|
|
||||||
|
### 21.2 Required prop families per event kind
|
||||||
|
|
||||||
|
Each event family carries a small required prop set. These are
|
||||||
|
load-bearing for the dashboard's cohort analysis; releases that
|
||||||
|
add a new event in an existing family **SHOULD** carry the
|
||||||
|
family's required props.
|
||||||
|
|
||||||
|
- **Navigation events** (`Page Viewed`, `RFC Viewed`): carry
|
||||||
|
`path` (string, pathname only — never the query string if it
|
||||||
|
could carry a token) for `Page Viewed`; carry `rfc_slug` for
|
||||||
|
`RFC Viewed`. `rfc_id` **MAY** be added when the cached row is
|
||||||
|
in hand.
|
||||||
|
- **Auth-state events** (`User Signed In`, `User Signed Out`):
|
||||||
|
`User Signed In` carries `method` (one of `'otc'`,
|
||||||
|
`'passcode'`, `'device-trust'`, `'admin-invite'`). `User
|
||||||
|
Signed Out` carries no props (the identity binding is cleared
|
||||||
|
separately via `anonymize()`).
|
||||||
|
- **Authored-action events** (`RFC Proposed`, `PR Opened`,
|
||||||
|
`Comment Posted`): carry `rfc_slug`. PRs additionally carry
|
||||||
|
`pr_number` once the row exists. Comments additionally carry
|
||||||
|
`thread_id`. None carry the body text.
|
||||||
|
- **Admin-action events** (`Beta Access Requested`,
|
||||||
|
`Admin Permission Decision`): the latter carries `action`
|
||||||
|
(lowercase: `'grant'` / `'revoke'`) and `target_user_id`.
|
||||||
|
- **Invite-side events** (`Invitation Sent`, `User Invited`): fire
|
||||||
|
from the inviter's signed-in session. `Invitation Sent` (per-RFC,
|
||||||
|
#12) carries `rfc_slug` + `role_in_rfc`. `User Invited`
|
||||||
|
(admin-create, #16) carries `target_user_id` (the OHM user_id of
|
||||||
|
the just-provisioned user) + `initial_role` +
|
||||||
|
`custom_message_chars` (a bounded integer signal of admin
|
||||||
|
effort, never the message text). Per #21 Part C: when the
|
||||||
|
invitee is not yet a user (#12 per-RFC invitations to an email
|
||||||
|
address that has never signed in), the invite-side event **MAY**
|
||||||
|
carry a hashed `target_email` fingerprint (SHA-256 of the
|
||||||
|
normalized lower-cased email) so the invite + claim pair can be
|
||||||
|
correlated later. Plain-text `target_email` **MUST NOT** be
|
||||||
|
carried.
|
||||||
|
- **Claim-side events** (`Invitation Accepted`, `Invite Claimed`):
|
||||||
|
fire from the invitee's session, immediately after an
|
||||||
|
`identify({ user_id, properties })` call binds the OHM user_id
|
||||||
|
to the Amplitude record (see §21.6). The events carry the
|
||||||
|
invite context (`rfc_slug` + `role_in_rfc` for the per-RFC
|
||||||
|
shape; `invited_by_admin_id` + `initial_role` + `needs_passcode`
|
||||||
|
+ `trust_device` for the admin-create shape).
|
||||||
|
|
||||||
|
When in doubt, the principle: a property is correctly shaped iff
|
||||||
|
the operator could publish it in a session transcript without
|
||||||
|
hesitation.
|
||||||
|
|
||||||
|
### 21.3 Autocapture-friendly DOM patterns
|
||||||
|
|
||||||
|
The wrapper initializes Amplitude with `analytics.autocapture: true`,
|
||||||
|
which auto-instruments page views, clicks, and form interactions.
|
||||||
|
The *value* of those auto-captured events depends entirely on the
|
||||||
|
DOM the SDK observes. Releases that add interactive UI **SHOULD**
|
||||||
|
follow these patterns so the dashboard rows are readable rather
|
||||||
|
than rows like "Click on `<button>` at `:nth-child(7)`".
|
||||||
|
|
||||||
|
- **Stable visible text on interactive elements.** Buttons and
|
||||||
|
links **SHOULD** have stable, human-readable text content (the
|
||||||
|
same string Amplitude uses to label the row). Avoid generic
|
||||||
|
labels like "Read more" / "Click here" that lose context.
|
||||||
|
- **`aria-label` on icon-only buttons.** Icon-only buttons (the
|
||||||
|
chevron expanders, kebab menus, close `X`s) **MUST** carry a
|
||||||
|
meaningful `aria-label`. Default autocapture for an unlabeled
|
||||||
|
icon button reads as garbage. The `aria-label` is also an
|
||||||
|
accessibility requirement — the two goals align.
|
||||||
|
- **`data-amp-track-*` for repeating-list per-row identifiers.**
|
||||||
|
When a list renders many rows of the same shape (RFC rows,
|
||||||
|
comment rows, PR rows in a listing), per-row interactive elements
|
||||||
|
**SHOULD** carry a `data-amp-track-name` attribute that
|
||||||
|
identifies the row's *kind* and a `data-amp-track-*` attribute
|
||||||
|
carrying the row's stable id. The convention:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button
|
||||||
|
data-amp-track-name="RFC Row Expand"
|
||||||
|
data-amp-track-rfc-slug={slug}
|
||||||
|
>…</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes per-RFC click counts aggregate to the RFC rather
|
||||||
|
than to a generic label, and lets the dashboard answer "which
|
||||||
|
RFCs got the most engagement" rather than "how many buttons
|
||||||
|
were clicked."
|
||||||
|
- **`data-amp-track-suppress` for noise surfaces.** Crowded surfaces
|
||||||
|
(the admin user-listing post-v0.9.0, the RFC discussion panel
|
||||||
|
during heavy review) **MAY** apply
|
||||||
|
`data-amp-track-suppress` (or its current equivalent in the
|
||||||
|
SDK version in use) to elements whose clicks would flood the
|
||||||
|
dashboard without informing anything. Suppression is a
|
||||||
|
deliberate decision; document it inline.
|
||||||
|
|
||||||
|
### 21.4 Session-replay masking conventions
|
||||||
|
|
||||||
|
The wrapper initializes Amplitude with `sessionReplay.sampleRate: 1`
|
||||||
|
(100% of consented sessions are recorded for full-DOM playback —
|
||||||
|
vendor-recommended default for new Amplitude deployments). Replay
|
||||||
|
has a meaningfully larger privacy footprint than event counters,
|
||||||
|
and the masking discipline is binding.
|
||||||
|
|
||||||
|
- **Credentials MUST be masked.** The OTC code input, passcode
|
||||||
|
input, any password-type field, the Turnstile widget internals,
|
||||||
|
the magic-link-claim token if it survives in the URL bar
|
||||||
|
(browser history, screenshot windows) — these **MUST** be masked
|
||||||
|
with Amplitude's masking convention (the `.amp-mask` class or the
|
||||||
|
`data-amp-mask` attribute, whichever the wrapper's SDK version
|
||||||
|
uses; the wrapper's bootstrap comment names the current
|
||||||
|
convention). Confirm each masking attribute survives the
|
||||||
|
wrapper init by inspecting a recorded session before each
|
||||||
|
release that touches an auth input.
|
||||||
|
- **PII SHOULD be masked or carefully un-masked.** Email-entry
|
||||||
|
fields, real-name capture fields (the v0.8.0 first/last/why
|
||||||
|
panel), free-text RFC body drafts, comment-compose text —
|
||||||
|
each is arguably PII or near-PII. The per-field decision is
|
||||||
|
the release's responsibility; document the choice in `SPEC.md`
|
||||||
|
§21 (this section) and in the release CHANGELOG so future
|
||||||
|
deployments inherit the call rather than re-deciding.
|
||||||
|
- **Privacy-policy alignment.** The recorded data **MUST** match
|
||||||
|
what the deployment's privacy / cookies policy claims. If
|
||||||
|
reality is broader than the document promises, update the policy
|
||||||
|
text in the same release.
|
||||||
|
- **Selective redaction.** Amplitude supports field-level mask
|
||||||
|
classes that hide value while preserving DOM shape (so the
|
||||||
|
session is replayable but the value is not). Prefer this over
|
||||||
|
whole-form masking when only a subset is sensitive.
|
||||||
|
|
||||||
|
### 21.5 Consent-gate contract
|
||||||
|
|
||||||
|
The wrapper is bound to the v0.13.0 cookie/privacy consent surface
|
||||||
|
(`frontend/src/lib/consent.js`, §14.5). The binding is **load-
|
||||||
|
bearing**: the SDK and the session-replay recorder **MUST NOT**
|
||||||
|
load before consent is granted, and any consent revocation **MUST**
|
||||||
|
take effect within one tick of the consent flip.
|
||||||
|
|
||||||
|
The wrapper's bootstrap implements this contract; releases that
|
||||||
|
touch the analytics surface **MUST** preserve it.
|
||||||
|
|
||||||
|
- **Pre-consent: no init, no network, no recording.** If
|
||||||
|
`consent.analytics === true` is not currently true (either
|
||||||
|
because the user denied, or because the banner is up and no
|
||||||
|
decision has been recorded), the wrapper **MUST NOT** import
|
||||||
|
the Amplitude SDK, **MUST NOT** open any network request to
|
||||||
|
Amplitude, and **MUST NOT** start any session-replay recording.
|
||||||
|
The consent-gated lazy `import('@amplitude/unified')` is the
|
||||||
|
binding implementation; preserve it.
|
||||||
|
- **Denied → granted: init at the consent moment.** When consent
|
||||||
|
flips from denied/undecided to granted, the wrapper **MUST**
|
||||||
|
initialize the SDK at that moment (a new `initAll(KEY, …)` call
|
||||||
|
through the lazy-import path). Track and identify calls made
|
||||||
|
before init resolves **MUST** be queued and drained on init,
|
||||||
|
so the first signed-in user's first event is not dropped on
|
||||||
|
the cold-load race.
|
||||||
|
- **Granted → denied: setOptOut(true) within one tick.** When
|
||||||
|
consent flips from granted to denied mid-session, the wrapper
|
||||||
|
**MUST** call `amplitude.setOptOut(true)` so subsequent events
|
||||||
|
are dropped client-side and session replay stops recording.
|
||||||
|
The wrapper cannot unload the script tag (the SDK is already
|
||||||
|
in memory), but the SDK's contract for "drop subsequent events"
|
||||||
|
is `setOptOut(true)`. This **MUST** happen within one tick of
|
||||||
|
the consent flip (i.e. synchronously inside the
|
||||||
|
`onConsentChange` handler).
|
||||||
|
- **No silent re-grant.** A granted → denied → granted sequence
|
||||||
|
**MUST** call `setOptOut(false)` (re-enabling the SDK that was
|
||||||
|
paused) rather than firing a second `initAll` (which would
|
||||||
|
double-init). The wrapper's bootstrap implements this; releases
|
||||||
|
that touch the consent integration **MUST** preserve the
|
||||||
|
distinction.
|
||||||
|
- **Build-time vs. runtime.** The API key is read from
|
||||||
|
`import.meta.env.VITE_AMPLITUDE_API_KEY` at build time. When
|
||||||
|
the env var is unset, the wrapper **MUST** log one console
|
||||||
|
warning and no-op (every public function becomes a deterministic
|
||||||
|
no-op) so dev environments without an Amplitude account keep
|
||||||
|
working. The deploy gesture binds the key via the deployment's
|
||||||
|
overlay verb (for OHM-shape deployments, `flotilla overlay set`);
|
||||||
|
see §21.8 for the secret-vs-public discussion.
|
||||||
|
|
||||||
|
### 21.6 Identity lifecycle (per #21 Part C)
|
||||||
|
|
||||||
|
Amplitude's identity model has a specific pattern that the
|
||||||
|
framework follows verbatim. Every release that touches an
|
||||||
|
identity-meaningful surface **MUST** observe this pattern. The
|
||||||
|
pattern shipped inline across v0.15.0 / v0.16.0 / v0.17.0
|
||||||
|
(Session L's wave); this section codifies the contract so future
|
||||||
|
releases inherit it.
|
||||||
|
|
||||||
|
**On sign-in success** (`App.jsx`'s `me.user` resolution):
|
||||||
|
|
||||||
|
- The wrapper's `identify({ user_id, properties })` call **MUST**
|
||||||
|
carry both the OHM user_id (`amplitude.setUserId(<id>)`
|
||||||
|
internally) AND the user's durable property bag. `setUserId`
|
||||||
|
alone is **NOT** sufficient — without properties, the Amplitude
|
||||||
|
user record carries only the id, and cohort analysis loses the
|
||||||
|
shape (role distribution, sign-in-method distribution, etc.)
|
||||||
|
the dashboard depends on.
|
||||||
|
- Properties **MUST** be a bag of opaque ids, enums, booleans,
|
||||||
|
and timestamps — no PII (no email, no display name, no IP).
|
||||||
|
- Properties **MUST** be classified `set` vs `setOnce` deliberately
|
||||||
|
(see §21.6.1 below).
|
||||||
|
- The same `identify` call **MUST** be re-issued on every sign-in
|
||||||
|
(idempotent at Amplitude's side; cheap; corrects any drift in
|
||||||
|
the mutable property half).
|
||||||
|
|
||||||
|
**On user-state change mid-session** (role grant/revoke, trust-
|
||||||
|
device add, passcode set, beta-permission flip):
|
||||||
|
|
||||||
|
- The wrapper's `setUserProperties(properties)` call **MUST** fire
|
||||||
|
so the Amplitude record stays current. Mid-session state changes
|
||||||
|
**MUST NOT** wait for the next sign-in to surface — the dashboard
|
||||||
|
cohort an admin uses to grant permission is the same dashboard
|
||||||
|
that next sees the granted user's behavior; staleness here breaks
|
||||||
|
the cohort feedback loop.
|
||||||
|
|
||||||
|
**On sign-out**:
|
||||||
|
|
||||||
|
- The wrapper's `anonymize()` call (internally `amplitude.reset()`)
|
||||||
|
**MUST** fire. `reset` clears the device-id linking AND **MUST**
|
||||||
|
also clear the property cache so the next anonymous session is a
|
||||||
|
fresh slate (the wrapper's `anonymize()` does both — releases
|
||||||
|
that touch the wrapper **MUST** preserve this).
|
||||||
|
- The `User Signed Out` `track()` call **MUST** fire *before*
|
||||||
|
`anonymize()`, so the sign-out event is correctly attributed to
|
||||||
|
the signing-out user rather than to the post-reset anonymous
|
||||||
|
device.
|
||||||
|
|
||||||
|
**On invite-claim** (the v0.16.0 per-RFC invite + v0.17.0 admin-
|
||||||
|
create invite paths):
|
||||||
|
|
||||||
|
- The wrapper's `identify({ user_id, properties })` call **MUST**
|
||||||
|
fire BEFORE the first `track()` event on the claim surface, so
|
||||||
|
the Amplitude user record is created with the OHM user_id from
|
||||||
|
the first event. **MUST NOT** fire `track()` first and `identify`
|
||||||
|
later — that creates an anonymous device record that
|
||||||
|
retroactively links, and the cohort attribution for
|
||||||
|
invite-driven onboarding loses precision.
|
||||||
|
- The invite-context properties (`claim_method`,
|
||||||
|
`invited_by_admin_id`, `invited_at`, `initial_role`) are
|
||||||
|
`setOnce` (immutable user-history markers) — see §21.6.1.
|
||||||
|
|
||||||
|
**Inviter-side identification on invite-send events**:
|
||||||
|
|
||||||
|
- The inviter's `track('Invitation Sent', …)` and
|
||||||
|
`track('User Invited', …)` events fire from the inviter's
|
||||||
|
signed-in session, so the `user_id` attribution is already
|
||||||
|
correct (it's the inviter's id). The event body carries
|
||||||
|
`target_user_id` (#16 — admin-create, where the future user is
|
||||||
|
provisioned at create-time) or a hashed `target_email`
|
||||||
|
fingerprint (#12 — per-RFC invite, where the invitee is not
|
||||||
|
yet a user) so the invite + claim pair can be correlated later
|
||||||
|
in the dashboard.
|
||||||
|
|
||||||
|
#### 21.6.1 `set` vs `setOnce` taxonomy
|
||||||
|
|
||||||
|
Amplitude distinguishes two property-write semantics:
|
||||||
|
|
||||||
|
- **`set(k, v)`** — overwrites the property on every call. The
|
||||||
|
user record reflects the most recent value.
|
||||||
|
- **`setOnce(k, v)`** — writes only if the property is not
|
||||||
|
already present. Subsequent calls are no-ops. The user record
|
||||||
|
reflects the first value ever written.
|
||||||
|
|
||||||
|
The wrapper's `applyProperties` function accepts both: a bare
|
||||||
|
value uses `.set()`; a sentinel-wrapped value
|
||||||
|
`['__setOnce__', value]` uses `.setOnce()`. Releases that add new
|
||||||
|
user properties **MUST** classify each one explicitly, by the
|
||||||
|
following rule:
|
||||||
|
|
||||||
|
- A property whose value is **expected to change over the user's
|
||||||
|
lifetime** is `set`. Examples: `role` (can flip from
|
||||||
|
`contributor` to `owner`), `permission_state` (pending → granted),
|
||||||
|
`passcode_set` (false → true), `device_trusted` (changes per
|
||||||
|
active device). On each sign-in, the latest value is written;
|
||||||
|
the dashboard always sees current state.
|
||||||
|
- A property that is an **immutable historical marker** is
|
||||||
|
`setOnce`. Examples: `first_sign_in_at` (the timestamp of the
|
||||||
|
user's first observed sign-in — never re-write), `account_
|
||||||
|
created_at` (the timestamp of provisioning),
|
||||||
|
`invited_by_admin_id` (the admin who provisioned this user via
|
||||||
|
the v0.17.0 path — preserved even if the user is later
|
||||||
|
re-invited or has their role changed), `invited_at` (the
|
||||||
|
timestamp at which the invite was sent — distinct from
|
||||||
|
`claim_method` which is also setOnce because once claim_method
|
||||||
|
is `'admin-invite'`, that's the path this user took).
|
||||||
|
|
||||||
|
The classification is part of the release's contract — flipping a
|
||||||
|
property from `set` to `setOnce` (or vice versa) mid-life corrupts
|
||||||
|
the user record and **MUST** be avoided. If a property's
|
||||||
|
semantics genuinely change, retire the old key and introduce a new
|
||||||
|
one (same deprecation discipline as event renames in §21.1).
|
||||||
|
|
||||||
|
### 21.7 Cohort-shape implications (informative)
|
||||||
|
|
||||||
|
The conventions above are designed so that the Amplitude dashboard
|
||||||
|
can answer cohort questions the operator actually asks:
|
||||||
|
|
||||||
|
- *"How many users signed in via the admin-invite path in week N
|
||||||
|
vs. organic OTC?"* — uses `claim_method` (setOnce) on the user
|
||||||
|
record + `User Signed In` events with `method`.
|
||||||
|
- *"Of admin-invited users, what fraction set a passcode within
|
||||||
|
their first session?"* — uses `claim_method` + `passcode_set`
|
||||||
|
on the user record + `Page Viewed` events to define "session."
|
||||||
|
- *"Which RFCs have the most owner-invited contributors?"* — uses
|
||||||
|
per-RFC `Invitation Sent` / `Invitation Accepted` correlated
|
||||||
|
via `rfc_slug` + `role_in_rfc`.
|
||||||
|
- *"What's the gap between invite-send and invite-claim, broken
|
||||||
|
out by inviter?"* — uses `Invitation Sent` (inviter's session,
|
||||||
|
inviter `user_id`) + `Invitation Accepted` (invitee's session,
|
||||||
|
invitee `user_id` after the BEFORE-track identify) joined on
|
||||||
|
`rfc_slug` + inviter (the inviter's id is the same id on both
|
||||||
|
events because both invite and claim sides observe it).
|
||||||
|
|
||||||
|
The Part-A audit (per `ohm-rfc/ROADMAP.md` #21) confirms these
|
||||||
|
shapes against real data once a week of beta traffic is in. The
|
||||||
|
audit is a point-in-time pass; this chapter is the standing
|
||||||
|
discipline that keeps future work in shape.
|
||||||
|
|
||||||
|
### 21.8 Secret vs. public — overlay binding
|
||||||
|
|
||||||
|
The Amplitude browser API key (`VITE_AMPLITUDE_API_KEY`) is
|
||||||
|
**bundle-embedded by design**: it appears as a literal string in
|
||||||
|
the deployment's JavaScript bundle, visible to anyone with browser
|
||||||
|
dev tools. The vendor's installation prompt embeds it inline. This
|
||||||
|
puts it in the same category as Cloudflare Turnstile's site key
|
||||||
|
(`VITE_TURNSTILE_SITE_KEY`, v0.12.0) — public, not secret.
|
||||||
|
|
||||||
|
Deployments **MUST** bind such keys via their overlay verb (for
|
||||||
|
OHM-shape deployments, `flotilla overlay set`), not via the secret
|
||||||
|
binding. The matching secret half (the Cloudflare Turnstile
|
||||||
|
**secret** key, `CLOUDFLARE_TURNSTILE_SECRET`, used server-side
|
||||||
|
for siteverify) is a true secret bound via `flotilla secret set`.
|
||||||
|
This per-key distinction is the deployment's responsibility; the
|
||||||
|
framework's `*.env.example` files name the binding for each.
|
||||||
|
|
||||||
|
The binding rule baked in mid-Session-K is: **the operator's
|
||||||
|
secret bytes never enter the conversation with an assistant**,
|
||||||
|
even as one offered option. The conversation-layer corollary of
|
||||||
|
the build-pipeline §3-invariant-1 rule from
|
||||||
|
`ohm-rfc-app-flotilla/SPEC.md` is that sessions publish in full,
|
||||||
|
and a secret in a transcript is a leaked secret. The canonical
|
||||||
|
secret-set gesture for OHM is `pbpaste | flotilla secret set
|
||||||
|
<deployment> <SECRET_NAME>` (the value goes clipboard → stdin →
|
||||||
|
Secret Manager without ever appearing in shell history or the
|
||||||
|
model context). Non-OHM deployments inherit the same discipline
|
||||||
|
through their own deploy tooling.
|
||||||
|
|
||||||
|
### 21.9 §19.2 candidates surfaced by this chapter
|
||||||
|
|
||||||
|
- **Session-replay-specific consent category.** v0.13.0's cookie
|
||||||
|
banner has a single `analytics` toggle that gates both event
|
||||||
|
counters and full-DOM session replay. Recording has a larger
|
||||||
|
privacy footprint than counters; a separate consent category
|
||||||
|
for session replay is the cleaner shape. Captured here and in
|
||||||
|
`ohm-rfc/ROADMAP.md` #21 Part A.
|
||||||
|
- **Bundle-size budget for the analytics wrapper.** The
|
||||||
|
`@amplitude/unified` package adds ~150 KB gzipped (the session-
|
||||||
|
replay recorder is the bulk). The consent-gated lazy import
|
||||||
|
keeps the cost off the initial bundle for users who haven't
|
||||||
|
opted in; the post-consent init path has not been measured
|
||||||
|
for jank. Captured in #21 Part A.
|
||||||
|
- **Property-shape lint.** The conventions in §21.1 / §21.2 are
|
||||||
|
enforced today by review discipline. A small lint (CI grep
|
||||||
|
against `track(` / `identify(` callsites with a property-key
|
||||||
|
allowlist + a PII-name denylist) is a future affordance that
|
||||||
|
catches drift mechanically.
|
||||||
|
- **Hashed `target_email` derivation.** §21.2 names SHA-256 of
|
||||||
|
the normalized lower-cased email as the hashing function. The
|
||||||
|
framework does not currently expose a helper for this — a
|
||||||
|
small `frontend/src/lib/hash.js` or `backend/app/hash.py` that
|
||||||
|
centralizes the normalization + hash would make the contract
|
||||||
|
enforceable. Captured here.
|
||||||
|
|
||||||
|
### 21.10 Open question
|
||||||
|
|
||||||
|
The wrapper currently uses the Amplitude SDK's autocapture +
|
||||||
|
session-replay defaults. The v0.13.0 consent surface has a single
|
||||||
|
toggle for "analytics." Splitting the consent into "analytics"
|
||||||
|
vs "session replay" is a §19.2 candidate (above) but settling it
|
||||||
|
also requires a privacy-policy update and a re-prompt of
|
||||||
|
existing consenters. The cleanest moment to do this is the next
|
||||||
|
material privacy-policy revision; the conventions in §21.4 hold
|
||||||
|
in the interim.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import PlainTextResponse, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
@@ -29,6 +30,8 @@ from . import (
|
|||||||
db,
|
db,
|
||||||
device_trust as device_trust_mod,
|
device_trust as device_trust_mod,
|
||||||
docs as docs_mod,
|
docs as docs_mod,
|
||||||
|
docs_sessions,
|
||||||
|
docs_specs,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
@@ -143,6 +146,177 @@ def make_router(
|
|||||||
payload = docs_mod.load()
|
payload = docs_mod.load()
|
||||||
return {"body": payload["body"]}
|
return {"body": payload["body"]}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
|
||||||
|
#
|
||||||
|
# The framework mediates reads against the public
|
||||||
|
# `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||||
|
# `/docs/sessions/*` surface inherits the same chrome as the
|
||||||
|
# /docs/user-guide route and doesn't require a cross-origin
|
||||||
|
# gesture from the frontend. See backend/app/docs_sessions.py
|
||||||
|
# for the cache shape and env knobs.
|
||||||
|
#
|
||||||
|
# The route mapping for the three `status` values returned by
|
||||||
|
# the fetchers:
|
||||||
|
#
|
||||||
|
# "ok" → HTTP 200, payload as documented per endpoint
|
||||||
|
# "404" → HTTP 200/404 depending on the endpoint (the
|
||||||
|
# manifest's empty state is 200 + {} so the
|
||||||
|
# frontend can short-circuit without an error
|
||||||
|
# banner; transcripts/about return 404 so the
|
||||||
|
# frontend can render its own empty-state)
|
||||||
|
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
|
||||||
|
# frontend retry surface reads as "couldn't reach
|
||||||
|
# the session-history repo" rather than as a
|
||||||
|
# generic 5xx.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/docs/sessions/manifest")
|
||||||
|
async def get_sessions_manifest() -> dict[str, Any]:
|
||||||
|
result = await docs_sessions.fetch_manifest()
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return result["manifest"]
|
||||||
|
if result["status"] == "404":
|
||||||
|
# Empty-state contract: render no session rows in the
|
||||||
|
# flyout but don't show an error banner. The frontend
|
||||||
|
# treats `{}` as "no sessions published yet".
|
||||||
|
return {}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "session-history fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/docs/sessions/about")
|
||||||
|
async def get_sessions_about() -> Response:
|
||||||
|
result = await docs_sessions.fetch_about()
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=result["body"],
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
)
|
||||||
|
if result["status"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="session-history README not yet published",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "session-history fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/docs/sessions/{nnnn}/index")
|
||||||
|
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
|
||||||
|
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||||
|
# 400 over 404: the request itself is malformed (the
|
||||||
|
# session directory name doesn't match `^\d{4}$`),
|
||||||
|
# distinct from "no such session published yet".
|
||||||
|
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||||
|
result = await docs_sessions.fetch_session_index(nnnn)
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return {"files": result["files"]}
|
||||||
|
if result["status"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="no transcripts published for this session",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "session-history fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/docs/sessions/{nnnn}/{filename}")
|
||||||
|
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
|
||||||
|
# Path-shape validation before any network — refuses anything
|
||||||
|
# that would resolve outside the `NNNN/SESSION-...md` layout
|
||||||
|
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
|
||||||
|
# `../etc/passwd`, or any non-numeric session dir).
|
||||||
|
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||||
|
if not docs_sessions._is_valid_transcript_filename(filename):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid transcript filename")
|
||||||
|
result = await docs_sessions.fetch_transcript(nnnn, filename)
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=result["body"],
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
)
|
||||||
|
if result["status"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="transcript not found",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "session-history fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.20.0 — /api/docs/specs/*
|
||||||
|
#
|
||||||
|
# Sibling of the v0.19.0 docs-sessions surface: the framework
|
||||||
|
# mediates a fetch against the public gitea raw URL for each
|
||||||
|
# configured spec so the rendered `/docs/specs/*` route inherits
|
||||||
|
# the same chrome (and the same auth-less reach) as the user
|
||||||
|
# guide and the session-history browser. See
|
||||||
|
# backend/app/docs_specs.py for the manifest shape, the env
|
||||||
|
# knobs, and the cache.
|
||||||
|
#
|
||||||
|
# Status-to-HTTP mapping mirrors docs_sessions:
|
||||||
|
# "ok" → HTTP 200, payload as documented per endpoint
|
||||||
|
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
|
||||||
|
# the manifest is derived from env, never 404s; spec
|
||||||
|
# 404 returns HTTP 404 so the frontend can render
|
||||||
|
# "spec not yet published / unknown name")
|
||||||
|
# "error" → HTTP 502
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/docs/specs/manifest")
|
||||||
|
async def get_specs_manifest() -> dict[str, Any]:
|
||||||
|
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
|
||||||
|
# never fails — a malformed value falls back to the framework
|
||||||
|
# default at parse time. So this endpoint always returns 200
|
||||||
|
# + a list (the framework default is non-empty).
|
||||||
|
result = docs_specs.fetch_specs_manifest()
|
||||||
|
return {"specs": result["specs"]}
|
||||||
|
|
||||||
|
@router.get("/api/docs/specs/{name}")
|
||||||
|
async def get_spec(name: str) -> Response:
|
||||||
|
# Slug validation before any network — refuses `..`, `/`,
|
||||||
|
# uppercase, whitespace, etc. Same defense-in-depth posture
|
||||||
|
# as the docs-sessions transcript endpoint.
|
||||||
|
if not docs_specs._is_valid_name(name):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid spec name")
|
||||||
|
result = await docs_specs.fetch_spec(name)
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=result["body"],
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
)
|
||||||
|
if result["status"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="spec not found",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "specs fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Auth surface — reads role from our users table per §6.
|
# Auth surface — reads role from our users table per §6.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""§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/<NNNN>/<file> — a transcript body
|
||||||
|
GET /api/docs/sessions/<NNNN>/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
|
||||||
|
# `<start>--<end>` 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/<NNNN> 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}"}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""v0.20.0 — on-site framework-specs surface source.
|
||||||
|
|
||||||
|
Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the
|
||||||
|
framework mediates a gitea fetch on behalf of the browser so the
|
||||||
|
rendered `/docs/specs/*` surface inherits the same chrome as
|
||||||
|
`/docs/user-guide` and `/docs/sessions/*` and stays free of any
|
||||||
|
cross-origin gestures from the frontend.
|
||||||
|
|
||||||
|
Two read endpoints, both anonymous-reachable:
|
||||||
|
|
||||||
|
GET /api/docs/specs/manifest — the configured spec list
|
||||||
|
GET /api/docs/specs/<name> — a single spec body (markdown)
|
||||||
|
|
||||||
|
The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md
|
||||||
|
+ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason
|
||||||
|
`docs_sessions.py`'s defaults are: OHM is the only deployment to
|
||||||
|
date. A deployment running its own spec set overrides the manifest
|
||||||
|
via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay).
|
||||||
|
History is intentionally not surfaced here — the operator-stated
|
||||||
|
intent is "current version only; git is the history surface".
|
||||||
|
|
||||||
|
Per-spec entries carry three fields:
|
||||||
|
|
||||||
|
name — URL-safe slug (`[a-z0-9-]+`) — the path segment
|
||||||
|
title — human-readable label shown in the nav and the page header
|
||||||
|
url — the upstream raw URL the framework fetches
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- The configured list must be a JSON array of `{name, title, url}`
|
||||||
|
objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong
|
||||||
|
shape, invalid slug) logs a warning and falls back to the default
|
||||||
|
so a typo in the overlay doesn't crash startup.
|
||||||
|
- Each `name` is checked against `^[a-z0-9-]+$` before the manifest
|
||||||
|
is accepted. The route layer also validates the path-bound `name`
|
||||||
|
parameter before any network call, so a malformed URL never
|
||||||
|
reaches the cache or the upstream.
|
||||||
|
|
||||||
|
Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic
|
||||||
|
TTL check, negative results (404) cached, no external dep. The
|
||||||
|
manifest is cheap (parsed from an env var, no network), so it has no
|
||||||
|
TTL — every request re-derives it. Per-spec content has a 5-minute
|
||||||
|
default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`).
|
||||||
|
|
||||||
|
§3 invariant 1 is preserved: the framework holds no secret bytes;
|
||||||
|
the upstream specs are public-repo raw URLs, the fetch carries no
|
||||||
|
auth header.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The framework-default spec set. OHM-flavored per the same precedent
|
||||||
|
# `docs_sessions.py` set: the only live deployment is OHM, so the
|
||||||
|
# default points there. A deployment running its own specs overrides
|
||||||
|
# `OHM_DOCS_SPECS` via the overlay.
|
||||||
|
_DEFAULT_SPECS: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app SPEC",
|
||||||
|
"url": (
|
||||||
|
"https://git.wiggleverse.org/ben.stull/rfc-app/"
|
||||||
|
"raw/branch/main/SPEC.md"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flotilla",
|
||||||
|
"title": "flotilla SPEC",
|
||||||
|
"url": (
|
||||||
|
"https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/"
|
||||||
|
"raw/branch/main/SPEC.md"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_DEFAULT_CONTENT_TTL_SEC = 300.0
|
||||||
|
|
||||||
|
# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit
|
||||||
|
# (rejecting anything that could resolve outside the intended layout)
|
||||||
|
# but with the lowercase-alphanumeric-plus-dash shape the manifest
|
||||||
|
# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase,
|
||||||
|
# and whitespace all fail this regex; the route layer rejects 400
|
||||||
|
# before any cache or network call.
|
||||||
|
_NAME_RE = re.compile(r"^[a-z0-9-]+$")
|
||||||
|
|
||||||
|
_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 _content_ttl() -> float:
|
||||||
|
return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_name(name: str) -> bool:
|
||||||
|
"""Slug guard for path-bound `name` parameters.
|
||||||
|
|
||||||
|
Mirrors `docs_sessions._is_valid_session_dir`'s contract: the
|
||||||
|
route layer calls this before any network or cache work, so a
|
||||||
|
malformed name never escapes the FastAPI surface.
|
||||||
|
"""
|
||||||
|
return bool(isinstance(name, str) and _NAME_RE.match(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_spec_entry(entry: Any) -> dict[str, str] | None:
|
||||||
|
"""Validate a single manifest entry; return None if invalid.
|
||||||
|
|
||||||
|
Required fields: `name`, `title`, `url`. All three must be
|
||||||
|
non-empty strings; `name` must match `_NAME_RE`. The validator is
|
||||||
|
strict: an entry that fails any check is dropped from the manifest
|
||||||
|
(and the caller logs at warning level).
|
||||||
|
"""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
name = entry.get("name")
|
||||||
|
title = entry.get("title")
|
||||||
|
url = entry.get("url")
|
||||||
|
if not isinstance(name, str) or not _is_valid_name(name):
|
||||||
|
return None
|
||||||
|
if not isinstance(title, str) or not title.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return None
|
||||||
|
return {"name": name, "title": title.strip(), "url": url.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configured_specs() -> list[dict[str, str]]:
|
||||||
|
"""Parse `OHM_DOCS_SPECS` (if set) or return the default list.
|
||||||
|
|
||||||
|
Malformed JSON or wrong-shape values log a warning and fall back
|
||||||
|
to the default — the deployment continues to render the spec
|
||||||
|
surface rather than crashing startup. The strict validation (each
|
||||||
|
entry's name slug, presence of all three fields) drops bad entries
|
||||||
|
one-by-one; if every entry is dropped, the default applies.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get("OHM_DOCS_SPECS", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS must be a JSON array — falling back to default"
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in parsed:
|
||||||
|
validated = _parse_spec_entry(entry)
|
||||||
|
if validated is None:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS entry %r failed validation — dropped", entry
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if validated["name"] in seen:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen.add(validated["name"])
|
||||||
|
out.append(validated)
|
||||||
|
if not out:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS yielded no valid entries — falling back to default"
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-process TTL cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Same shape as `docs_sessions.py`: 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:
|
||||||
|
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, same convention as
|
||||||
|
# `docs_sessions.py`:
|
||||||
|
# "ok" — payload field carries the body / manifest
|
||||||
|
# "404" — gitea returned 404 (or the configured name doesn't exist)
|
||||||
|
# "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("specs fetch failed for %s: %s", url, e)
|
||||||
|
return 599, f"fetch error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_specs_manifest() -> dict[str, Any]:
|
||||||
|
"""Return the configured spec manifest.
|
||||||
|
|
||||||
|
The manifest is derived from the `OHM_DOCS_SPECS` env var (or the
|
||||||
|
framework default if unset / malformed) and carries no network
|
||||||
|
work — it's safe to call on every request. The return shape mirrors
|
||||||
|
the docs_sessions manifest endpoint for frontend consistency:
|
||||||
|
|
||||||
|
{"status": "ok", "specs": [{"name", "title", "url"}, ...]}
|
||||||
|
|
||||||
|
The "url" field is exposed in the manifest so the frontend can
|
||||||
|
offer a "view source on gitea" affordance alongside each rendered
|
||||||
|
spec (operator-stated intent: "include the history so you can see
|
||||||
|
it in git" — that gesture lives in the source link, not on the
|
||||||
|
rendered page).
|
||||||
|
"""
|
||||||
|
specs = _load_configured_specs()
|
||||||
|
return {"status": "ok", "specs": specs}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_spec(name: str) -> dict[str, Any]:
|
||||||
|
"""Fetch a single spec body by its manifest `name`.
|
||||||
|
|
||||||
|
The caller is expected to have validated `name` against
|
||||||
|
`_is_valid_name` before calling this — an invalid name shouldn't
|
||||||
|
reach the network. We re-check inside as defense-in-depth: a
|
||||||
|
bogus name here returns the same `{status: "404"}` shape so the
|
||||||
|
route layer's `404 → HTTP 404` mapping handles it uniformly.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
{"status": "ok", "body": "..."}
|
||||||
|
{"status": "404"} — no such spec OR upstream 404
|
||||||
|
{"status": "error", "detail": "..."} — upstream 5xx / timeout
|
||||||
|
"""
|
||||||
|
if not _is_valid_name(name):
|
||||||
|
return {"status": "404"}
|
||||||
|
|
||||||
|
cache_key = f"spec:{name}"
|
||||||
|
cached = _cache_get(cache_key, _content_ttl())
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
specs = _load_configured_specs()
|
||||||
|
match = next((s for s in specs if s["name"] == name), None)
|
||||||
|
if match is None:
|
||||||
|
# Cache the negative — a deployment with an unstable manifest
|
||||||
|
# would still benefit from the TTL window, and the cached 404
|
||||||
|
# is automatically displaced when the next request happens
|
||||||
|
# after TTL expiry.
|
||||||
|
payload: dict[str, Any] = {"status": "404"}
|
||||||
|
_cache_put(cache_key, payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
url = match["url"]
|
||||||
|
status, body = await _http_get(url)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
payload = {"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}"}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
|
||||||
|
|
||||||
|
The framework mediates reads against the public
|
||||||
|
`wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||||
|
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
|
||||||
|
This test suite covers the four endpoints + the in-process TTL cache,
|
||||||
|
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
|
||||||
|
rest of the test suite uses for Gitea).
|
||||||
|
|
||||||
|
The tests do NOT spin up the full FakeGitea — they only need to mock
|
||||||
|
the gitea raw URL surface (and the contents API for the session-index
|
||||||
|
endpoint). Each test owns its mock transport so we can dial in 200 /
|
||||||
|
404 / 5xx / timeout responses per case.
|
||||||
|
|
||||||
|
Path-validation tests intentionally bypass the network — a malformed
|
||||||
|
`nnnn` or `filename` MUST be rejected at the route layer before any
|
||||||
|
upstream call is made.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import docs_sessions
|
||||||
|
|
||||||
|
# Reuse the proven app-construction fixtures from the proposal vertical
|
||||||
|
# (same shape every test file in this repo uses).
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test scaffolding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _UpstreamHandler:
|
||||||
|
"""Records every URL the docs_sessions module fetched and returns
|
||||||
|
canned responses keyed by URL substring. Allows the test to assert
|
||||||
|
on call count (for cache verification) without needing a full Gitea
|
||||||
|
simulator.
|
||||||
|
|
||||||
|
`calls` tracks only URLs that hit the session-history host (the
|
||||||
|
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls — which
|
||||||
|
also pass through this handler because `httpx.AsyncClient` is a
|
||||||
|
shared attribute the gitea-side fixture also monkeypatches — don't
|
||||||
|
inflate the count we use for cache-hit assertions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
|
||||||
|
|
||||||
|
def __init__(self, responses: dict[str, tuple[int, str]]):
|
||||||
|
self.responses = responses
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if any(m in url for m in self._SESSION_HOST_MARKERS):
|
||||||
|
self.calls.append(url)
|
||||||
|
for key, (status, body) in self.responses.items():
|
||||||
|
if key in url:
|
||||||
|
return httpx.Response(status, text=body)
|
||||||
|
# Default: 404. Lets tests skip declaring "the rest is 404".
|
||||||
|
return httpx.Response(404, text="not found")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_httpx(monkeypatch):
|
||||||
|
"""Provide a hook the test can call to install a MockTransport.
|
||||||
|
|
||||||
|
Returns a closure: `install(handler)` patches
|
||||||
|
`app.docs_sessions.httpx.AsyncClient` so every constructed client
|
||||||
|
uses the handler's transport.
|
||||||
|
|
||||||
|
NB: the upstream `app_with_fake_gitea` fixture also patches
|
||||||
|
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
|
||||||
|
and because `httpx` is a single shared module, that patch mutates
|
||||||
|
the *same* `AsyncClient` attribute we're about to overwrite. We
|
||||||
|
therefore import the unpatched class directly from the
|
||||||
|
`httpx._client` module so our install path can construct a fresh
|
||||||
|
real client around our MockTransport without going through the
|
||||||
|
FakeGitea wrapper.
|
||||||
|
"""
|
||||||
|
from httpx._client import AsyncClient as RealAsyncClient
|
||||||
|
|
||||||
|
def install(handler):
|
||||||
|
def patched(*args, **kwargs):
|
||||||
|
kwargs["transport"] = httpx.MockTransport(handler)
|
||||||
|
return RealAsyncClient(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
|
||||||
|
return handler
|
||||||
|
|
||||||
|
yield install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(app_with_fake_gitea):
|
||||||
|
"""Wrap the shared app fixture, resetting the docs-sessions cache so
|
||||||
|
cross-test state can't leak. Returns just the FastAPI app — the
|
||||||
|
fake-Gitea handle is irrelevant for the docs-sessions surface.
|
||||||
|
"""
|
||||||
|
docs_sessions.reset_cache()
|
||||||
|
fastapi_app, _fake = app_with_fake_gitea
|
||||||
|
return fastapi_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_happy_path(app, patched_httpx):
|
||||||
|
manifest_body = json.dumps(
|
||||||
|
{
|
||||||
|
"0001": {"title": "Bootstrap"},
|
||||||
|
"0014": {"title": "Wave 7 driver"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
patched_httpx(
|
||||||
|
_UpstreamHandler({"sessions.json": (200, manifest_body)})
|
||||||
|
)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
assert payload == {
|
||||||
|
"0001": {"title": "Bootstrap"},
|
||||||
|
"0014": {"title": "Wave 7 driver"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_empty_state(app, patched_httpx):
|
||||||
|
"""A 404 from gitea means the manifest hasn't been published yet.
|
||||||
|
The endpoint returns HTTP 200 + `{}` so the frontend can render the
|
||||||
|
no-sessions-yet state without an error banner.
|
||||||
|
"""
|
||||||
|
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
|
||||||
|
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/manifest")
|
||||||
|
assert r.status_code == 502, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["detail"]["error"] == "session-history fetch failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# About endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_about_happy_path(app, patched_httpx):
|
||||||
|
readme = "# OHM session history\n\nWelcome.\n"
|
||||||
|
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/about")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "text/markdown" in r.headers["content-type"]
|
||||||
|
assert r.text == readme
|
||||||
|
|
||||||
|
|
||||||
|
def test_about_404(app, patched_httpx):
|
||||||
|
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/about")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_about_upstream_5xx_returns_502(app, patched_httpx):
|
||||||
|
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/about")
|
||||||
|
assert r.status_code == 502, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transcript endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_happy_path(app, patched_httpx):
|
||||||
|
body = "# Session 0017.1 — Transcript\n\nbody.\n"
|
||||||
|
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
|
||||||
|
patched_httpx(_UpstreamHandler({fname: (200, body)}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get(f"/api/docs/sessions/0017/{fname}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "text/markdown" in r.headers["content-type"]
|
||||||
|
assert r.text == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_404(app, patched_httpx):
|
||||||
|
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||||
|
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
|
||||||
|
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
|
||||||
|
network call.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get(
|
||||||
|
"/api/docs/sessions/abcd/"
|
||||||
|
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert handler.calls == [], "rejected path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_rejects_path_traversal(app, patched_httpx):
|
||||||
|
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
|
||||||
|
is rejected. `../etc/passwd` doesn't match; neither does the legacy
|
||||||
|
flat-root `SESSION-A-TRANSCRIPT.md`.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Path traversal — but FastAPI normalizes `..` in the path before
|
||||||
|
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
|
||||||
|
# which routes to the same handler with filename=etc/passwd, and
|
||||||
|
# gets rejected as an invalid transcript filename. Even if the
|
||||||
|
# normalization didn't apply (some intermediary), the regex
|
||||||
|
# check rejects anything not matching the SESSION- prefix.
|
||||||
|
r = client.get(
|
||||||
|
"/api/docs/sessions/0001/etc%2Fpasswd"
|
||||||
|
)
|
||||||
|
# 400 (filename validation) or 404 (path didn't match the
|
||||||
|
# route); both reject before any network call. Either is
|
||||||
|
# acceptable — what matters is that we never fetched it.
|
||||||
|
assert r.status_code in (400, 404), r.text
|
||||||
|
assert handler.calls == [], "rejected path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
|
||||||
|
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
|
||||||
|
numeric regex — by design, since post-#23 transcripts live in
|
||||||
|
`NNNN/` folders with numeric names. Reject 400.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert handler.calls == [], "rejected path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
|
||||||
|
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||||
|
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||||
|
assert r.status_code == 502, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session-index endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_index_happy_path(app, patched_httpx):
|
||||||
|
"""The contents API returns a JSON list of file entries. The
|
||||||
|
endpoint filters to entries that match the transcript regex and
|
||||||
|
sorts them.
|
||||||
|
"""
|
||||||
|
# Two transcripts (driver + subagent) + a non-transcript sibling
|
||||||
|
# that must be filtered out.
|
||||||
|
listing = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "SESSION-0017.0-TRANSCRIPT-"
|
||||||
|
"2026-05-28T08-30--2026-05-28T12-00.md",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SESSION-0017.1-TRANSCRIPT-"
|
||||||
|
"2026-05-28T08-50--2026-05-28T11-20.md",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
{"name": "notes.md", "type": "file"}, # not a transcript
|
||||||
|
{"name": "attached-dir", "type": "dir"}, # not a file
|
||||||
|
]
|
||||||
|
)
|
||||||
|
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/0017/index")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
files = r.json()["files"]
|
||||||
|
assert files == [
|
||||||
|
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
|
||||||
|
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_index_404(app, patched_httpx):
|
||||||
|
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/9999/index")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/sessions/abc/index")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert handler.calls == [], "rejected path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||||
|
"""Two consecutive manifest calls within the TTL window should
|
||||||
|
issue exactly one HTTP request to gitea.
|
||||||
|
"""
|
||||||
|
# Generous TTL so the test never races.
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
|
||||||
|
handler = _UpstreamHandler(
|
||||||
|
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
|
||||||
|
)
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get("/api/docs/sessions/manifest")
|
||||||
|
r2 = client.get("/api/docs/sessions/manifest")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(handler.calls) == 1, (
|
||||||
|
f"expected one upstream call, got {handler.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||||
|
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||||
|
handler = _UpstreamHandler({fname: (200, "# body\n")})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||||
|
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(handler.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
|
||||||
|
"""Negative caching: a 404 result is cached at the content TTL so a
|
||||||
|
deployment with no published transcripts doesn't hammer gitea on
|
||||||
|
every navigation. Documented in `docs_sessions.fetch_transcript`.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||||
|
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||||
|
handler = _UpstreamHandler({}) # everything 404s
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||||
|
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||||
|
assert r1.status_code == 404
|
||||||
|
assert r2.status_code == 404
|
||||||
|
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""v0.20.0 — `/api/docs/specs/*` endpoints.
|
||||||
|
|
||||||
|
Sibling of `test_docs_sessions_vertical.py`. The framework mediates
|
||||||
|
reads of the configured framework-spec URLs (default: rfc-app's own
|
||||||
|
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||||
|
`/docs/specs/*` surface inherits the same chrome as
|
||||||
|
`/docs/user-guide` and `/docs/sessions/*`.
|
||||||
|
|
||||||
|
This file covers:
|
||||||
|
|
||||||
|
- The manifest endpoint with the framework default
|
||||||
|
- The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
|
||||||
|
- Slug validation at the route layer (rejects `..`, `/`, `~`,
|
||||||
|
uppercase, whitespace, path traversal attempts)
|
||||||
|
- Gitea 200 / 404 / 5xx response mapping
|
||||||
|
- Negative caching (404 is cached, not re-fetched within TTL)
|
||||||
|
- Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged
|
||||||
|
warning (asserted by caplog)
|
||||||
|
- A manifest entry that fails per-entry validation (bad slug,
|
||||||
|
missing field) is dropped, with the rest of the list retained
|
||||||
|
|
||||||
|
Mocking approach: same as docs_sessions — `httpx.MockTransport`
|
||||||
|
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import docs_specs
|
||||||
|
|
||||||
|
# Reuse the proven app-construction fixtures from the proposal vertical
|
||||||
|
# (same shape every test file in this repo uses).
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test scaffolding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _UpstreamHandler:
|
||||||
|
"""Records every URL the docs_specs module fetched and returns
|
||||||
|
canned responses keyed by URL substring. Lets the test assert on
|
||||||
|
call count (for cache verification) without booting a full upstream
|
||||||
|
simulator.
|
||||||
|
|
||||||
|
`calls` tracks only URLs that hit a host configured in the spec
|
||||||
|
manifest under test — so unrelated httpx clients (gitea-side
|
||||||
|
fixtures, etc.) don't inflate the count we use for cache-hit
|
||||||
|
assertions. We marker-match on substrings the manifest carries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
responses: dict[str, tuple[int, str]],
|
||||||
|
host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
|
||||||
|
):
|
||||||
|
self.responses = responses
|
||||||
|
self.host_markers = host_markers
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if any(m in url for m in self.host_markers):
|
||||||
|
self.calls.append(url)
|
||||||
|
for key, (status, body) in self.responses.items():
|
||||||
|
if key in url:
|
||||||
|
return httpx.Response(status, text=body)
|
||||||
|
# Default: 404. Lets tests skip declaring "the rest is 404".
|
||||||
|
return httpx.Response(404, text="not found")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_httpx(monkeypatch):
|
||||||
|
"""Provide a hook the test can call to install a MockTransport.
|
||||||
|
|
||||||
|
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||||
|
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||||
|
construct from the unpatched class directly to avoid the
|
||||||
|
FakeGitea wrapper.
|
||||||
|
"""
|
||||||
|
from httpx._client import AsyncClient as RealAsyncClient
|
||||||
|
|
||||||
|
def install(handler):
|
||||||
|
def patched(*args, **kwargs):
|
||||||
|
kwargs["transport"] = httpx.MockTransport(handler)
|
||||||
|
return RealAsyncClient(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
|
||||||
|
return handler
|
||||||
|
|
||||||
|
yield install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(app_with_fake_gitea):
|
||||||
|
"""Reset the docs-specs cache so cross-test state can't leak."""
|
||||||
|
docs_specs.reset_cache()
|
||||||
|
fastapi_app, _fake = app_with_fake_gitea
|
||||||
|
return fastapi_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_default(app, monkeypatch):
|
||||||
|
"""With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
|
||||||
|
framework default (rfc-app + flotilla).
|
||||||
|
"""
|
||||||
|
monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
assert "specs" in payload
|
||||||
|
names = [s["name"] for s in payload["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
# The default URLs point at the OHM-canonical gitea raw paths.
|
||||||
|
assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_overridden(app, monkeypatch):
|
||||||
|
"""A deployment overriding `OHM_DOCS_SPECS` gets its custom list.
|
||||||
|
|
||||||
|
The manifest is parsed per-request from the env var (no startup
|
||||||
|
binding) so a runtime overlay change is visible without a
|
||||||
|
restart — same shape as the docs_sessions env knobs.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "custom-spec",
|
||||||
|
"title": "Custom Spec",
|
||||||
|
"url": "https://specs.example.org/CUSTOM.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
assert payload == {
|
||||||
|
"specs": [
|
||||||
|
{
|
||||||
|
"name": "custom-spec",
|
||||||
|
"title": "Custom Spec",
|
||||||
|
"url": "https://specs.example.org/CUSTOM.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
|
||||||
|
endpoint falls back to the framework default. Startup is
|
||||||
|
unaffected — the deployment continues to render the spec surface
|
||||||
|
rather than crashing on the typo.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
names = [s["name"] for s in payload["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"OHM_DOCS_SPECS is not valid JSON" in rec.message
|
||||||
|
for rec in caplog.records
|
||||||
|
), f"expected a logged warning; got {[r.message for r in caplog.records]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
|
||||||
|
non-list value) falls back to the default + logs a warning.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"must be a JSON array" in rec.message for rec in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
|
||||||
|
"""Per-entry validation: an entry with a bad slug or missing field
|
||||||
|
is dropped; valid entries in the same list are retained.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space
|
||||||
|
{"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal
|
||||||
|
{"name": "missing-url", "title": "Missing URL"}, # no url
|
||||||
|
{
|
||||||
|
"name": "good-spec",
|
||||||
|
"title": "Good",
|
||||||
|
"url": "https://specs.example.org/GOOD.md",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["good-spec"]
|
||||||
|
# Three drop warnings (one per bad entry).
|
||||||
|
drops = [r for r in caplog.records if "failed validation" in r.message]
|
||||||
|
assert len(drops) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""If every entry is dropped, the framework default applies (the
|
||||||
|
surface never goes empty due to a bad overlay).
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"yielded no valid entries" in rec.message for rec in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
|
||||||
|
"""A duplicate `name` is dropped (the first occurrence wins). The
|
||||||
|
route layer's `/api/docs/specs/{name}` path lookup is by name, so
|
||||||
|
duplicates would otherwise be ambiguous.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "x", "title": "First", "url": "https://specs.example/1"},
|
||||||
|
{"name": "x", "title": "Second", "url": "https://specs.example/2"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
payload = r.json()
|
||||||
|
assert [s["title"] for s in payload["specs"]] == ["First"]
|
||||||
|
assert any("duplicate name" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec endpoint — happy + error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_happy_path(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app SPEC",
|
||||||
|
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
body = "# rfc-app SPEC\n\nSection 1...\n"
|
||||||
|
patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/rfc-app")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "text/markdown" in r.headers["content-type"]
|
||||||
|
assert r.text == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_upstream_404(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "missing-spec",
|
||||||
|
"title": "Missing",
|
||||||
|
"url": "https://specs.example.org/missing.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/missing-spec")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "broken-spec",
|
||||||
|
"title": "Broken",
|
||||||
|
"url": "https://specs.example.org/broken.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/broken-spec")
|
||||||
|
assert r.status_code == 502, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["detail"]["error"] == "specs fetch failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
|
||||||
|
"""A name that doesn't appear in the manifest returns 404 without
|
||||||
|
touching the network. The handler treats "no such configured spec"
|
||||||
|
and "upstream 404" as the same outcome — both render the same
|
||||||
|
"spec not found" empty state on the frontend.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app",
|
||||||
|
"url": "https://specs.example.org/x.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/does-not-exist")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert handler.calls == [], "unknown-name lookup must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec endpoint — slug validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_name",
|
||||||
|
[
|
||||||
|
"UPPER", # uppercase
|
||||||
|
"spaces here", # whitespace (post-decoding)
|
||||||
|
"with~tilde", # tilde
|
||||||
|
"with.dot", # dot
|
||||||
|
"with_under", # underscore (not allowed by [a-z0-9-]+)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
|
||||||
|
"""Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
|
||||||
|
the route layer before any network or cache work.
|
||||||
|
|
||||||
|
Note: `..` is intentionally not in this list because the URL-
|
||||||
|
parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
|
||||||
|
before the handler is reached — the path-traversal protection is
|
||||||
|
therefore framework-level (httpx/urllib's path normalizer) rather
|
||||||
|
than route-layer. The slug-validation guard still rejects any
|
||||||
|
`..` that *would* reach the handler (e.g. via an env-configured
|
||||||
|
manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
|
||||||
|
for that path.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert handler.calls == [], "rejected name must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_rejects_slash_in_name(app, patched_httpx):
|
||||||
|
"""A literal `/` in the path can't make it through the path
|
||||||
|
parameter — FastAPI routes it as a separate segment. The check
|
||||||
|
here is that the request never reaches an upstream fetch.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# `/api/docs/specs/sub/path` — the second segment makes this
|
||||||
|
# not match the `/{name}` route at all; FastAPI returns 404.
|
||||||
|
r = client.get("/api/docs/specs/sub/path")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert handler.calls == [], "non-matching path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app",
|
||||||
|
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get("/api/docs/specs/rfc-app")
|
||||||
|
r2 = client.get("/api/docs/specs/rfc-app")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(handler.calls) == 1, (
|
||||||
|
f"expected one upstream call, got {handler.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
|
||||||
|
"""Negative caching: a 404 result is cached at the content TTL so
|
||||||
|
a deployment with a misconfigured spec URL doesn't hammer the
|
||||||
|
upstream on every navigation.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "missing-spec",
|
||||||
|
"title": "Missing",
|
||||||
|
"url": "https://specs.example.org/missing.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({}) # everything 404s
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get("/api/docs/specs/missing-spec")
|
||||||
|
r2 = client.get("/api/docs/specs/missing-spec")
|
||||||
|
assert r1.status_code == 404
|
||||||
|
assert r2.status_code == 404
|
||||||
|
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.20.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.20.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/unified": "^1.1.9",
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.20.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2193,3 +2193,242 @@
|
|||||||
font-size: 11px; text-transform: uppercase;
|
font-size: 11px; text-transform: uppercase;
|
||||||
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
|
v0.19.0 / roadmap item #30 — /docs/* flyout + sessions browser.
|
||||||
|
|
||||||
|
The shell is `.docs-layout` (header + body). The body is a flex
|
||||||
|
row: `.docs-nav` is the persistent left sidebar on desktop and a
|
||||||
|
slide-out drawer on mobile (toggled by the `.docs-drawer-toggle`
|
||||||
|
icon in `.docs-header`). The content area `.docs-content` mounts
|
||||||
|
the sub-route via React Router's <Outlet/>.
|
||||||
|
|
||||||
|
The article body inside each sub-route reuses `.philosophy-body`
|
||||||
|
(defined above) for the markdown rendering — same `marked` lib,
|
||||||
|
same typography. The new classes here just handle the chrome
|
||||||
|
(sidebar + drawer + header).
|
||||||
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.docs-layout {
|
||||||
|
height: 100%;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 24px 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0ee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.docs-back, .docs-drawer-toggle {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
color: #4b5563; font-size: 13px;
|
||||||
|
padding: 4px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.docs-back:hover, .docs-drawer-toggle:hover {
|
||||||
|
background: #f3f4f6; color: #111;
|
||||||
|
}
|
||||||
|
.docs-drawer-toggle {
|
||||||
|
display: none;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.docs-title {
|
||||||
|
font-size: 13px; color: #6b7280;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.docs-signin {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px; color: #4b5563; text-decoration: none;
|
||||||
|
}
|
||||||
|
.docs-signin:hover { color: #111; text-decoration: underline; }
|
||||||
|
|
||||||
|
.docs-body {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 260px;
|
||||||
|
border-right: 1px solid #f0f0ee;
|
||||||
|
padding: 24px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fafaf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-inner { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
.docs-nav-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.docs-nav-section-label {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.docs-nav-list {
|
||||||
|
list-style: none; padding: 0; margin: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.docs-nav-list a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 10px; border-radius: 4px;
|
||||||
|
color: #1f2937; text-decoration: none;
|
||||||
|
font-size: 14px; line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.docs-nav-list a:hover { background: #f0f0ee; }
|
||||||
|
.docs-nav-list a.active {
|
||||||
|
background: #e7e5e4; color: #111; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.20.0 — nested transcript rows under each session row. The parent
|
||||||
|
<li> holds the session link; the nested <ul> holds the transcript
|
||||||
|
children. Indent + a softer font color so the hierarchy reads at a
|
||||||
|
glance. */
|
||||||
|
.docs-nav-list--children {
|
||||||
|
list-style: none; padding: 0; margin: 2px 0 8px 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
border-left: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.docs-nav-list--children a {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px; color: #4b5563;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.docs-nav-list--children a.active {
|
||||||
|
background: #e7e5e4; color: #111; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-skeleton .skeleton-row {
|
||||||
|
display: block;
|
||||||
|
height: 14px; margin: 8px 10px;
|
||||||
|
background: linear-gradient(90deg, #f0f0ee 25%, #e7e5e4 50%, #f0f0ee 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: docs-skeleton-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes docs-skeleton-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-error {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px; color: #9a3412;
|
||||||
|
background: #fff7ed; border: 1px solid #fed7aa; border-radius: 4px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.docs-nav-error button {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #fff; border: 1px solid #fed7aa;
|
||||||
|
color: #9a3412; font-size: 12px;
|
||||||
|
padding: 4px 8px; border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.docs-nav-error button:hover { background: #fff7ed; }
|
||||||
|
|
||||||
|
.docs-content {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 32px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-article {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.docs-article-title {
|
||||||
|
font-size: 28px; font-weight: 700; margin: 0 0 24px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
.docs-breadcrumbs { margin: 0 0 16px; font-size: 13px; }
|
||||||
|
.docs-breadcrumbs a {
|
||||||
|
color: #4b5563; text-decoration: none;
|
||||||
|
}
|
||||||
|
.docs-breadcrumbs a:hover { color: #111; text-decoration: underline; }
|
||||||
|
|
||||||
|
.docs-session-files {
|
||||||
|
list-style: none; padding: 0; margin: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.docs-session-files a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px; border-radius: 6px;
|
||||||
|
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||||
|
color: #1f2937; text-decoration: none;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px; word-break: break-all;
|
||||||
|
}
|
||||||
|
.docs-session-files a:hover {
|
||||||
|
background: #f3f4f6; border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.20.0 — session-overview body. Replaces the per-session body
|
||||||
|
transcript-link list; transcripts are now picked from the left
|
||||||
|
nav. The body just states the count + the nav hint. */
|
||||||
|
.docs-session-overview {
|
||||||
|
padding: 16px; border-radius: 6px;
|
||||||
|
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||||
|
font-size: 14px; line-height: 1.6; color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.20.0 — spec article header (title + "View source" link sit on
|
||||||
|
the same row, wrapping on narrow widths). */
|
||||||
|
.docs-article-header {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 12px; margin: 0 0 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.docs-article-header .docs-article-title { margin: 0; }
|
||||||
|
.docs-source-link {
|
||||||
|
font-size: 13px; color: #4b5563; text-decoration: none;
|
||||||
|
padding: 4px 10px; border-radius: 4px; border: 1px solid #e5e7eb;
|
||||||
|
background: #fafaf9;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.docs-source-link:hover { background: #f3f4f6; color: #111; }
|
||||||
|
|
||||||
|
.docs-empty, .docs-error {
|
||||||
|
padding: 16px; border-radius: 6px;
|
||||||
|
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||||
|
font-size: 14px; line-height: 1.6; color: #4b5563;
|
||||||
|
}
|
||||||
|
.docs-error { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
|
||||||
|
.docs-error button {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #fff; border: 1px solid #fecaca;
|
||||||
|
color: #991b1b; font-size: 13px;
|
||||||
|
padding: 6px 12px; border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.docs-error button:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
.docs-drawer-scrim {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: none; padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: the sidebar becomes a slide-out drawer. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.docs-drawer-toggle { display: inline-block; }
|
||||||
|
.docs-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0; left: 0;
|
||||||
|
width: 80%; max-width: 320px;
|
||||||
|
z-index: 10;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 200ms ease-out;
|
||||||
|
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.docs-body--drawer-open .docs-nav {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.docs-body--drawer-open .docs-drawer-scrim {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.docs-content { padding: 16px 18px 64px; }
|
||||||
|
}
|
||||||
|
|||||||
+36
-4
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { getMe, subscribeToNotifications } from './api'
|
import { getMe, subscribeToNotifications } from './api'
|
||||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
@@ -12,7 +12,13 @@ import Landing from './components/Landing.jsx'
|
|||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
import Docs from './components/Docs.jsx'
|
import DocsLayout from './components/DocsLayout.jsx'
|
||||||
|
import DocsUserGuide from './components/DocsUserGuide.jsx'
|
||||||
|
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
||||||
|
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
||||||
|
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
||||||
|
import DocsSpec from './components/DocsSpec.jsx'
|
||||||
|
import DocsSpecsIndex from './components/DocsSpecsIndex.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||||
@@ -233,7 +239,13 @@ export default function App() {
|
|||||||
itself establishes the session on success. */}
|
itself establishes the session on success. */}
|
||||||
<Route path="/invites/claim" element={<InviteClaim />} />
|
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
{/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav.
|
||||||
|
The bare /docs path redirects to the user guide; sessions
|
||||||
|
browser lives at /docs/sessions/*. See DocsLayout.jsx
|
||||||
|
for the flyout shape and CHANGELOG v0.19.0 for the
|
||||||
|
upgrade path. */}
|
||||||
|
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
|
||||||
|
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
|
||||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
Available to anonymous and authenticated viewers alike. */}
|
Available to anonymous and authenticated viewers alike. */}
|
||||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||||
@@ -303,9 +315,29 @@ function PhilosophyWithSidebar({ viewer }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DocsWithSidebar({ viewer }) {
|
function DocsWithSidebar({ viewer }) {
|
||||||
|
// v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout
|
||||||
|
// shell with sub-routes. The shell (sidebar + content area) is the
|
||||||
|
// DocsLayout outlet host; the sub-routes mount their respective
|
||||||
|
// pages into the outlet. Bare `/docs/sessions` redirects to the
|
||||||
|
// sessions about page so deep-linkers and the flyout's "Sessions"
|
||||||
|
// header both land somewhere coherent.
|
||||||
return (
|
return (
|
||||||
<main className="chrome-pane">
|
<main className="chrome-pane">
|
||||||
<Docs authenticated={!!viewer} />
|
<Routes>
|
||||||
|
<Route element={<DocsLayout authenticated={!!viewer} />}>
|
||||||
|
<Route index element={<Navigate to="user-guide" replace />} />
|
||||||
|
<Route path="user-guide" element={<DocsUserGuide />} />
|
||||||
|
<Route path="sessions" element={<Navigate to="about" replace />} />
|
||||||
|
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||||||
|
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||||||
|
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||||||
|
{/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec
|
||||||
|
at runtime via gitea raw). Bare /docs/specs lands on the
|
||||||
|
client-side redirect to the first configured spec. */}
|
||||||
|
<Route path="specs" element={<DocsSpecsIndex />} />
|
||||||
|
<Route path="specs/:name" element={<DocsSpec />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -720,6 +720,85 @@ export async function getDocs() {
|
|||||||
return jsonOrThrow(await fetch('/api/docs'))
|
return jsonOrThrow(await fetch('/api/docs'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The framework mediates reads against the public
|
||||||
|
// `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||||
|
// `/docs/sessions/*` surface inherits the same chrome as
|
||||||
|
// `/docs/user-guide`. Three text-bearing endpoints return markdown
|
||||||
|
// (Content-Type: text/markdown) and the manifest returns JSON. We
|
||||||
|
// wrap each into a small helper.
|
||||||
|
//
|
||||||
|
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
|
||||||
|
// throws an Error with `.status === 404` so the UI can render its own
|
||||||
|
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
|
||||||
|
// the UI can offer a retry button.
|
||||||
|
|
||||||
|
export async function getSessionsManifest() {
|
||||||
|
// Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
|
||||||
|
// helper never throws on the empty-state path.
|
||||||
|
return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _textOrThrow(res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = ''
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
detail = body.detail || JSON.stringify(body)
|
||||||
|
} catch {
|
||||||
|
detail = await res.text()
|
||||||
|
}
|
||||||
|
const error = new Error(detail || `HTTP ${res.status}`)
|
||||||
|
error.status = res.status
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionsAbout() {
|
||||||
|
return _textOrThrow(await fetch('/api/docs/sessions/about'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionTranscript(nnnn, filename) {
|
||||||
|
return _textOrThrow(await fetch(
|
||||||
|
`/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionIndex(nnnn) {
|
||||||
|
return jsonOrThrow(await fetch(
|
||||||
|
`/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.20.0 — /api/docs/specs/* surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Sibling of the docs-sessions helpers above. The framework mediates
|
||||||
|
// reads against the configured spec URLs (default: rfc-app's own
|
||||||
|
// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||||
|
// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide`
|
||||||
|
// and `/docs/sessions/*`. The manifest endpoint always returns 200 +
|
||||||
|
// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to
|
||||||
|
// the framework default at parse time on the backend.
|
||||||
|
//
|
||||||
|
// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`,
|
||||||
|
// matching the docs-sessions helper convention.
|
||||||
|
|
||||||
|
export async function getSpecsManifest() {
|
||||||
|
return jsonOrThrow(await fetch('/api/docs/specs/manifest'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpec(name) {
|
||||||
|
return _textOrThrow(await fetch(
|
||||||
|
`/api/docs/specs/${encodeURIComponent(name)}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
// `/docs` — the user-facing guide.
|
|
||||||
//
|
|
||||||
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
|
||||||
// source file. Renders DOCS.md verbatim with light chrome around it.
|
|
||||||
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
|
||||||
// the guide before deciding to sign in.
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
|
||||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
|
||||||
import { getDocs } from '../api.js'
|
|
||||||
|
|
||||||
export default function Docs({ authenticated }) {
|
|
||||||
const [body, setBody] = useState('')
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
getDocs()
|
|
||||||
.then(r => { if (active) setBody(r.body || '') })
|
|
||||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
|
||||||
.finally(() => { if (active) setLoading(false) })
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="philosophy-page">
|
|
||||||
<header className="philosophy-header">
|
|
||||||
<button
|
|
||||||
className="philosophy-back"
|
|
||||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
<span className="philosophy-title">User guide</span>
|
|
||||||
{!authenticated && (
|
|
||||||
<Link className="philosophy-signin" to="/">Home</Link>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<article className="philosophy-body">
|
|
||||||
{loading && <p className="muted">Loading…</p>}
|
|
||||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<MarkdownPreview content={body} />
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// DocsLayout.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
|
||||||
|
//
|
||||||
|
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
||||||
|
//
|
||||||
|
// /docs → redirect to /docs/user-guide
|
||||||
|
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
||||||
|
// /docs/specs → client-side redirect to first configured spec
|
||||||
|
// /docs/specs/:name → a single framework spec (v0.20.0)
|
||||||
|
// /docs/sessions → redirect to /docs/sessions/about
|
||||||
|
// /docs/sessions/about → README.md from the sessions repo
|
||||||
|
// /docs/sessions/:nnnn → per-session overview (nav-only navigation)
|
||||||
|
// /docs/sessions/:nnnn/:file → per-transcript view
|
||||||
|
//
|
||||||
|
// v0.20.0 changes (Session 0018.0):
|
||||||
|
// - Adds a "Specs" section between User Guide and Sessions, driven
|
||||||
|
// by `/api/docs/specs/manifest`.
|
||||||
|
// - Sessions render a nested tree: each session row has the
|
||||||
|
// session's transcripts nested under it as their own nav rows
|
||||||
|
// (labeled by `.N` ordinal). The transcript list is fetched per
|
||||||
|
// session via `/api/docs/sessions/:nnnn/index` (cached server-
|
||||||
|
// side, so the manifest+index fan-out is cheap on subsequent
|
||||||
|
// loads). Always-expanded; no collapse toggle (current scale is
|
||||||
|
// under twenty sessions — well under the threshold where lazy
|
||||||
|
// expansion would pay).
|
||||||
|
//
|
||||||
|
// The flyout is a persistent left sidebar on desktop and a slide-out
|
||||||
|
// drawer on mobile (toggled by the icon button in the docs header).
|
||||||
|
//
|
||||||
|
// Amplitude analytics (per SPEC §21):
|
||||||
|
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
||||||
|
// the sub-route component owns the fire.
|
||||||
|
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
||||||
|
// so autocapture rows are readable rather than ":nth-child(7)".
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||||
|
import { getSessionsManifest, getSessionIndex, getSpecsManifest } from '../api.js'
|
||||||
|
|
||||||
|
// Extract the `.N` ordinal from a transcript filename:
|
||||||
|
// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1"
|
||||||
|
// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent)
|
||||||
|
// Returns the bare filename as fallback if the expected shape isn't
|
||||||
|
// matched (which shouldn't happen — the backend index endpoint
|
||||||
|
// filters by the same regex).
|
||||||
|
function transcriptOrdinal(filename) {
|
||||||
|
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename)
|
||||||
|
return m ? m[1] : filename
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocsLayout({ authenticated }) {
|
||||||
|
const [manifest, setManifest] = useState(null)
|
||||||
|
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
||||||
|
const [sessionFiles, setSessionFiles] = useState({}) // { nnnn: [filename, ...] }
|
||||||
|
const [specs, setSpecs] = useState([])
|
||||||
|
const [specsState, setSpecsState] = useState('loading') // loading | ok | error
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Manifest fetch — drives the Sessions section.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setManifestState('loading')
|
||||||
|
getSessionsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
setManifest(payload || {})
|
||||||
|
setManifestState('ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setManifest({})
|
||||||
|
setManifestState('error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reloadTick])
|
||||||
|
|
||||||
|
// Per-session transcript lists — fan out from the manifest. Always-
|
||||||
|
// expanded means we pre-fetch every session's index alongside the
|
||||||
|
// manifest, gated on the manifest having loaded successfully. The
|
||||||
|
// backend's 5-minute content TTL makes the repeat cost negligible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (manifestState !== 'ok' || !manifest) return
|
||||||
|
let active = true
|
||||||
|
const nnnnList = Object.keys(manifest).sort()
|
||||||
|
Promise.all(
|
||||||
|
nnnnList.map(nnnn =>
|
||||||
|
getSessionIndex(nnnn)
|
||||||
|
.then(payload => [nnnn, (payload && payload.files) || []])
|
||||||
|
.catch(() => [nnnn, []])
|
||||||
|
)
|
||||||
|
).then(pairs => {
|
||||||
|
if (!active) return
|
||||||
|
setSessionFiles(Object.fromEntries(pairs))
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [manifest, manifestState])
|
||||||
|
|
||||||
|
// Specs fetch — drives the Specs section. Independent of sessions.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setSpecsState('loading')
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
setSpecs((payload && payload.specs) || [])
|
||||||
|
setSpecsState('ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setSpecs([])
|
||||||
|
setSpecsState('error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reloadTick])
|
||||||
|
|
||||||
|
// Close the mobile drawer on every navigation so a click in the nav
|
||||||
|
// doesn't strand the user on a drawer-open view.
|
||||||
|
useEffect(() => {
|
||||||
|
setDrawerOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
const retryManifest = useCallback(() => {
|
||||||
|
setReloadTick(t => t + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docs-layout">
|
||||||
|
<header className="docs-header">
|
||||||
|
<button
|
||||||
|
className="docs-back"
|
||||||
|
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||||
|
aria-label="Back to previous page"
|
||||||
|
data-amp-track-name="Docs Back"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="docs-drawer-toggle"
|
||||||
|
onClick={() => setDrawerOpen(o => !o)}
|
||||||
|
aria-label="Toggle docs navigation"
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
data-amp-track-name="Docs Drawer Toggle"
|
||||||
|
>
|
||||||
|
<span aria-hidden>☰</span>
|
||||||
|
</button>
|
||||||
|
<span className="docs-title">Docs</span>
|
||||||
|
{!authenticated && (
|
||||||
|
<Link
|
||||||
|
className="docs-signin"
|
||||||
|
to="/"
|
||||||
|
aria-label="Home"
|
||||||
|
data-amp-track-name="Docs Home"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className={'docs-body' + (drawerOpen ? ' docs-body--drawer-open' : '')}>
|
||||||
|
<aside className="docs-nav" aria-label="Docs navigation">
|
||||||
|
<DocsNav
|
||||||
|
manifest={manifest}
|
||||||
|
manifestState={manifestState}
|
||||||
|
sessionFiles={sessionFiles}
|
||||||
|
specs={specs}
|
||||||
|
specsState={specsState}
|
||||||
|
onRetry={retryManifest}
|
||||||
|
currentPath={location.pathname}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
<main className="docs-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{drawerOpen && (
|
||||||
|
<button
|
||||||
|
className="docs-drawer-scrim"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
data-amp-track-name="Docs Drawer Close"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsNav({
|
||||||
|
manifest,
|
||||||
|
manifestState,
|
||||||
|
sessionFiles,
|
||||||
|
specs,
|
||||||
|
specsState,
|
||||||
|
onRetry,
|
||||||
|
currentPath,
|
||||||
|
}) {
|
||||||
|
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
||||||
|
const isExactly = (path) => currentPath === path
|
||||||
|
|
||||||
|
const sessionKeys = Object.keys(manifest || {}).sort()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="docs-nav-inner">
|
||||||
|
<div className="docs-nav-section">
|
||||||
|
<div className="docs-nav-section-label">Docs</div>
|
||||||
|
<ul className="docs-nav-list">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to="/docs/user-guide"
|
||||||
|
className={isActive('/docs/user-guide') ? 'active' : ''}
|
||||||
|
aria-label="User Guide"
|
||||||
|
data-amp-track-name="Docs Nav User Guide"
|
||||||
|
>
|
||||||
|
User Guide
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="docs-nav-section">
|
||||||
|
<div className="docs-nav-section-label">Specs</div>
|
||||||
|
{specsState === 'loading' && (
|
||||||
|
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{specsState === 'error' && (
|
||||||
|
<div className="docs-nav-error" role="alert">
|
||||||
|
<span>Couldn't load specs.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{specsState === 'ok' && specs.length > 0 && (
|
||||||
|
<ul className="docs-nav-list">
|
||||||
|
{specs.map(spec => {
|
||||||
|
const to = `/docs/specs/${spec.name}`
|
||||||
|
return (
|
||||||
|
<li key={spec.name}>
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={isExactly(to) ? 'active' : ''}
|
||||||
|
aria-label={`Spec: ${spec.title}`}
|
||||||
|
data-amp-track-name="Docs Nav Spec"
|
||||||
|
data-amp-track-spec={spec.name}
|
||||||
|
>
|
||||||
|
{spec.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="docs-nav-section">
|
||||||
|
<div className="docs-nav-section-label">Sessions</div>
|
||||||
|
<ul className="docs-nav-list">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to="/docs/sessions/about"
|
||||||
|
className={isExactly('/docs/sessions/about') ? 'active' : ''}
|
||||||
|
aria-label="About sessions"
|
||||||
|
data-amp-track-name="Docs Nav Sessions About"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{manifestState === 'loading' && (
|
||||||
|
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manifestState === 'error' && (
|
||||||
|
<div className="docs-nav-error" role="alert">
|
||||||
|
<span>Couldn't load session list.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
aria-label="Retry session list"
|
||||||
|
data-amp-track-name="Docs Nav Sessions Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||||
|
<ul className="docs-nav-list docs-nav-list--tree">
|
||||||
|
{sessionKeys.map(nnnn => {
|
||||||
|
const entry = manifest[nnnn] || {}
|
||||||
|
const title = entry.title || ''
|
||||||
|
const label = title ? `${nnnn} — ${title}` : nnnn
|
||||||
|
const to = `/docs/sessions/${nnnn}`
|
||||||
|
const files = sessionFiles[nnnn] || []
|
||||||
|
return (
|
||||||
|
<li key={nnnn}>
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={isExactly(to) ? 'active' : ''}
|
||||||
|
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||||
|
data-amp-track-name="Docs Nav Session"
|
||||||
|
data-amp-track-session={nnnn}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="docs-nav-list docs-nav-list--children">
|
||||||
|
{files.map(f => {
|
||||||
|
const tTo = `/docs/sessions/${nnnn}/${f}`
|
||||||
|
return (
|
||||||
|
<li key={f}>
|
||||||
|
<Link
|
||||||
|
to={tTo}
|
||||||
|
className={isExactly(tTo) ? 'active' : ''}
|
||||||
|
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||||
|
data-amp-track-name="Docs Nav Transcript"
|
||||||
|
data-amp-track-session={nnnn}
|
||||||
|
data-amp-track-filename={f}
|
||||||
|
>
|
||||||
|
{transcriptOrdinal(f)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// DocsSessionIndex.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
|
||||||
|
//
|
||||||
|
// Per-session landing at `/docs/sessions/:nnnn`. v0.19.0 listed the
|
||||||
|
// transcripts as body links; v0.20.0 drops the body list — navigation
|
||||||
|
// is via the left flyout nav (which renders each session's transcripts
|
||||||
|
// nested under the session row). The body now serves as a
|
||||||
|
// session-overview card with the title, file count, and a hint to
|
||||||
|
// pick a transcript from the nav.
|
||||||
|
//
|
||||||
|
// The transcript count still comes from `/api/docs/sessions/:nnnn/index`
|
||||||
|
// so the empty-state ("no transcripts yet"), not-found, and error
|
||||||
|
// paths remain meaningful — the page still does something useful when
|
||||||
|
// the upstream is mid-publish or unreachable.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { getSessionsManifest, getSessionIndex } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionIndex() {
|
||||||
|
const { nnnn } = useParams()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||||
|
}, [nnnn])
|
||||||
|
|
||||||
|
// Title from manifest — cheap, manifest is cached server-side.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSessionsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const entry = payload && payload[nnnn]
|
||||||
|
setTitle((entry && entry.title) || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Title is decorative; failure to load just leaves the header
|
||||||
|
// showing the bare NNNN. The transcript list fetch below is
|
||||||
|
// the load-bearing one.
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn])
|
||||||
|
|
||||||
|
// File list from the per-session index endpoint.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionIndex(nnnn)
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
setFiles((payload && payload.files) || [])
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>
|
||||||
|
No transcripts have been published for this session yet.{' '}
|
||||||
|
<Link
|
||||||
|
to="/docs/sessions/about"
|
||||||
|
aria-label="About sessions"
|
||||||
|
data-amp-track-name="Docs Session Empty About Link"
|
||||||
|
>
|
||||||
|
About sessions
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Session Index Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && files.length === 0 && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This session has no transcripts published.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && files.length > 0 && (
|
||||||
|
<div className="docs-session-overview">
|
||||||
|
<p>
|
||||||
|
{files.length === 1
|
||||||
|
? '1 transcript in this session.'
|
||||||
|
: `${files.length} transcripts in this session.`}{' '}
|
||||||
|
Select one from the navigation on the left.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||||
|
// transcript body via the backend mediator and renders it through the
|
||||||
|
// shared MarkdownPreview.
|
||||||
|
//
|
||||||
|
// Empty-state contract:
|
||||||
|
// 404 → "This transcript isn't published yet" with a link back to
|
||||||
|
// the parent session index
|
||||||
|
// 502 → "Couldn't reach the session-history repo" + retry button
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSessionTranscript } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionTranscript() {
|
||||||
|
const { nnnn, filename } = useParams()
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||||
|
}, [nnnn, filename])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionTranscript(nnnn, filename)
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn, filename, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<div className="docs-breadcrumbs">
|
||||||
|
<Link
|
||||||
|
to={`/docs/sessions/${nnnn}`}
|
||||||
|
aria-label={`Back to session ${nnnn} index`}
|
||||||
|
data-amp-track-name="Docs Transcript Back To Index"
|
||||||
|
>
|
||||||
|
← Session {nnnn}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This transcript isn't published yet.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to={`/docs/sessions/${nnnn}`}
|
||||||
|
aria-label={`Back to session ${nnnn}`}
|
||||||
|
data-amp-track-name="Docs Transcript Back To Session"
|
||||||
|
>
|
||||||
|
← Back to session {nnnn}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Transcript Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// DocsSessionsAbout.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Renders the README.md of the public `wiggleverse/ohm-session-history`
|
||||||
|
// repo at `/docs/sessions/about`. The framework backend mediates the
|
||||||
|
// gitea fetch (see backend/app/docs_sessions.py); this component
|
||||||
|
// handles three response paths:
|
||||||
|
//
|
||||||
|
// 200 → render the markdown via MarkdownPreview
|
||||||
|
// 404 → "About not yet published" empty-state (the upstream README
|
||||||
|
// doesn't exist yet — happens when a deployment hasn't
|
||||||
|
// restructured its session-history repo yet, expected at
|
||||||
|
// v0.19.0 deploy time per the CHANGELOG)
|
||||||
|
// 502 → "Couldn't reach the session-history repo" with a retry
|
||||||
|
// button. The retry just re-invokes the fetch — no extra
|
||||||
|
// backoff because the backend cache already smoothes
|
||||||
|
// repeated 502s.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSessionsAbout } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionsAbout() {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'sessions/about' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionsAbout()
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">About sessions</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>
|
||||||
|
The session-history About page isn't published yet. Sessions
|
||||||
|
are still authored — once a few have shipped, this page will
|
||||||
|
render the canonical introduction.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In the meantime, browse the source repo directly at{' '}
|
||||||
|
<a
|
||||||
|
href="https://git.wiggleverse.org/wiggleverse/ohm-session-history"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Open session-history repo on gitea"
|
||||||
|
data-amp-track-name="Docs Sessions About Repo Link"
|
||||||
|
>
|
||||||
|
wiggleverse/ohm-session-history
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Sessions About Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// DocsSpec.jsx — v0.20.0.
|
||||||
|
//
|
||||||
|
// Per-spec view at `/docs/specs/:name`. Fetches a configured spec
|
||||||
|
// body via the backend mediator (which proxies the gitea raw URL)
|
||||||
|
// and renders it through the shared MarkdownPreview. The page header
|
||||||
|
// pulls the spec's `title` from the manifest so the breadcrumb-free
|
||||||
|
// page still names what you're looking at.
|
||||||
|
//
|
||||||
|
// Empty-state contract:
|
||||||
|
// 404 → "This spec isn't published yet / unknown name" with a hint
|
||||||
|
// to pick a configured spec from the nav.
|
||||||
|
// 502 → "Couldn't reach the spec source" + retry button.
|
||||||
|
//
|
||||||
|
// History view is intentionally absent — the operator's framing for
|
||||||
|
// v0.20.0 is "current version only; git is the history surface".
|
||||||
|
// A small "View on gitea" link beside the title points at the
|
||||||
|
// upstream source URL the manifest carries so the history gesture
|
||||||
|
// remains one click away.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSpec, getSpecsManifest } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSpec() {
|
||||||
|
const { name } = useParams()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `specs/${name}` })
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
// Title + upstream URL from the manifest (cheap — the manifest is
|
||||||
|
// derived from an env var on the backend, no network).
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const entry = (payload && payload.specs || []).find(s => s.name === name)
|
||||||
|
setTitle((entry && entry.title) || '')
|
||||||
|
setSourceUrl((entry && entry.url) || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Title + source link are decorative; the body fetch below
|
||||||
|
// is the load-bearing one. A failed manifest fetch just
|
||||||
|
// leaves the page rendering the bare name.
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSpec(name)
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [name, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
const header = title || `Spec: ${name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<header className="docs-article-header">
|
||||||
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
{sourceUrl && (
|
||||||
|
<a
|
||||||
|
className="docs-source-link"
|
||||||
|
href={sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="View spec source on gitea"
|
||||||
|
data-amp-track-name="Docs Spec Source Link"
|
||||||
|
data-amp-track-spec={name}
|
||||||
|
>
|
||||||
|
View source
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This spec isn't available.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to="/docs/user-guide"
|
||||||
|
aria-label="User guide"
|
||||||
|
data-amp-track-name="Docs Spec Notfound User Guide Link"
|
||||||
|
>
|
||||||
|
← Back to the user guide
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the spec source.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Spec Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// DocsSpecsIndex.jsx — v0.20.0.
|
||||||
|
//
|
||||||
|
// Landing route at `/docs/specs`. The operator-stated body shape for
|
||||||
|
// the parallel `/docs/sessions/:nnnn` page is "no body list — pick a
|
||||||
|
// transcript from the nav", and the same gesture applies here: the
|
||||||
|
// `/docs/specs` route either redirects to the first configured spec
|
||||||
|
// (the common case) or renders a "no specs configured" empty state
|
||||||
|
// (only reachable if a deployment overrides `OHM_DOCS_SPECS` to an
|
||||||
|
// empty list — the framework default has two entries).
|
||||||
|
//
|
||||||
|
// The redirect is client-side because the manifest is a single API
|
||||||
|
// call away; server-side redirect would require either a backend
|
||||||
|
// route for the bare `/docs/specs` path (out of scope for v0.20.0)
|
||||||
|
// or a build-time bake of the first spec name (which couples the
|
||||||
|
// frontend bundle to the deployment overlay, which we don't do).
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigate, Link } from 'react-router-dom'
|
||||||
|
import { getSpecsManifest } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSpecsIndex() {
|
||||||
|
const [firstName, setFirstName] = useState(null)
|
||||||
|
// Tri-state: 'loading' (waiting on manifest), 'redirect' (we have a
|
||||||
|
// name to redirect to — render <Navigate>), 'empty' (no specs
|
||||||
|
// configured), or 'error' (couldn't load the manifest at all).
|
||||||
|
const [status, setStatus] = useState('loading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'specs' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const specs = (payload && payload.specs) || []
|
||||||
|
if (specs.length === 0) {
|
||||||
|
setStatus('empty')
|
||||||
|
} else {
|
||||||
|
setFirstName(specs[0].name)
|
||||||
|
setStatus('redirect')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setStatus('error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (status === 'redirect' && firstName) {
|
||||||
|
return <Navigate to={firstName} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">Specs</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'empty' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>No specs are configured for this deployment.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to="/docs/user-guide"
|
||||||
|
aria-label="User guide"
|
||||||
|
data-amp-track-name="Docs Specs Empty User Guide Link"
|
||||||
|
>
|
||||||
|
← Back to the user guide
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't load the spec list.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// DocsUserGuide.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Renders DOCS.md at `/docs/user-guide`. Was `/docs` before v0.19.0
|
||||||
|
// (the v0.14.0 single-route Docs.jsx surface, now superseded). The
|
||||||
|
// content path is unchanged: backend reads `DOCS.md` from disk and
|
||||||
|
// serves it at `/api/docs`. The body is rendered via the existing
|
||||||
|
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||||
|
// so we don't introduce a second markdown library).
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getDocs } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsUserGuide() {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getDocs()
|
||||||
|
.then(r => { if (active) setBody(r.body || '') })
|
||||||
|
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||||
|
.finally(() => { if (active) setLoading(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">User guide</h1>
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,6 +97,13 @@ export const EVENTS = Object.freeze({
|
|||||||
// v0.17.0 / item #16 — admin-create user + invite email.
|
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||||
USER_INVITED: 'User Invited',
|
USER_INVITED: 'User Invited',
|
||||||
INVITE_CLAIMED: 'Invite Claimed',
|
INVITE_CLAIMED: 'Invite Claimed',
|
||||||
|
// v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries
|
||||||
|
// `section`: 'user-guide' | 'sessions/about' | 'sessions/<NNNN>' |
|
||||||
|
// 'sessions/<NNNN>/<filename>' so the dashboard can answer which
|
||||||
|
// docs surfaces get read most. The transcript-section value includes
|
||||||
|
// the filename so an aggregator can group by `sessions/<NNNN>` or by
|
||||||
|
// exact transcript.
|
||||||
|
DOC_VIEWED: 'Doc Viewed',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Internal state.
|
// Internal state.
|
||||||
|
|||||||
Reference in New Issue
Block a user