Compare commits

...
6 Commits
Author SHA1 Message Date
ducoterra 1200437bf8 phase: 168_admin_settings_panel
Build and Push Containers / build-and-push-app (push) Successful in 4m4s
Build and Push Containers / build-and-push-db (push) Successful in 15s
**Phase 168 — Admin Settings panel — final verification: PASS**

1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
2026-10-10 01:05:14 -04:00
ducoterra abe7388dda docs(phases): convert TODO.md L3-L4 into phases 170-171 (edit bubble, settings layout)
170_edit_bubble_sizing — the edit form renders inside the user bubble
(same box, no shrink) and its submit label reads "Send", not "Save"
(TODO.md L3).
171_admin_settings_layout — settings page CSS fixes: #settings-content
vertical rhythm (search/table overlap + smushed action row), distinct
group bands, a properly disabled Save cursor (TODO.md L4).

Numbering note: 169 (embedding generation migration) was created,
executed and abandoned before this conversion; its number is not
reused. TODO.md is cleared — the items now live in .agents/phases/.

LLM-Generated: true
2026-10-09 23:42:15 -04:00
ducoterra 542e568d25 docs(agent): record the reverted embed embedding benchmark
The embed alias was reverted to the previous backing model and re-benchmarked
over 3 runs. All gates pass and the numbers reproduce the original baseline
exactly: 768 dims, cosine margin 0.283 (quality 56) versus the 0.217 measured
on the swapped-in model, with throughput back to 109.7-123.3 vec/s.

The margin returning to the identical 0.283 confirms the locked semantic-pair
corpus is a reliable fingerprint for this endpoint, and a raw-vector probe
shows the two models are genuinely different spaces despite both being
unit-normalized 768-dim (max component diff 0.035 on the same input). Any
content embedded while the swapped-in model was live is therefore in an
incompatible space and needs re-embedding.

LLM-Generated: true
2026-10-09 12:41:38 -04:00
ducoterra a88a5a3713 docs(agent): re-run the embed embedding benchmark (speed variance)
Second 3-run pass against the re-pointed embed endpoint the same day.
Dimension stays 768/768 and the cosine margin reproduces exactly at 0.217
across all passes, confirming both the model's determinism and that the
margin drop from the 0.283 baseline is a real effect of the model swap.
Throughput measured 59.1/61.4/57.4 vec/s against 35.5/35.6 earlier, a 1.7x
swing on an unchanged model, which shows the speed check measures one batched
request's end-to-end wall time (TLS setup, network, server queueing) rather
than stable throughput - note it gates on NaN/Inf only, never on rate.

LLM-Generated: true
2026-10-09 12:38:27 -04:00
ducoterra 05f7d0645e docs(phases): add phase 169 (embedding generation stamp + in-place re-embed) and the migrate-embed-model skill
An embedding-model swap that keeps the dimension is invisible on this
app: nothing records which model made a vector, EmbeddingDimensionError
cannot fire at 768 -> 768, and a re-sync re-embeds nothing because the
documents.file_hash fast path returns with zero embeds - so questions
embed in one geometry and chunks score in another with no error anywhere.
The owner's upgrade (embedding gemma -> embedding gemma 2) is exactly
that case.

Phase 169, seven tasks: chunks.embedding_model (reversible migration
0029, nullable, never backfilled - NULL means unknown by design), one
write helper so every vector write stamps its generation, a
generation_report() that turns the column into counts plus a stale
verdict logged after every sync, scripts/reembed_embeddings.py with
--compare (cosine agreement against the stored vectors, so 'are they
compatible?' is measured instead of guessed) / --stamp-only / --apply
(per-batch commits, resumable, never a NULL embedding, never a
sources_meta bump), an admin-only GET /api/admin/embeddings, a read-only
card in the phase-168 Settings view, and the dedicated Playwright file
plus the README runbook.

Owner-locked during authoring: detection only - retrieval never filters
by generation (a WHERE embedding_model predicate would silently deflect
every turn mid-backfill), comparability bands COMPATIBLE >= 0.995 for
the float4 round trip, a re-embed is not a KB change so saved chats stay
fresh, and no new Settings field so the phase-168 catalog pin is
untouched.

.agents/skills/migrate-embed-model is the operator twin: probe and
benchmark the new model, measure comparability as its own process
without disturbing the running app, back up and migrate production
(idle check, pg_dump off-box, alembic, flip then re-embed with the same
env), then re-tune relevance_threshold and the two floors against the
retrieval battery - after the corpus is on one generation, and with the
phase-168 warning that an app_settings row beats .env - and smoke-test
the result.

LLM-Generated: true
2026-10-09 12:35:21 -04:00
ducoterra 8fac4e64d4 docs(agent): record the re-pointed embed embedding benchmark
The aipi `embed` endpoint was re-pointed to a new backing model and was
re-benchmarked over 3 runs. All three gates pass: output stays 768-dim
(matching BOR_EMBEDDING_DIM, so no schema/vector change is needed), the
similar-vs-dissimilar cosine margin is 0.217 (gate >= 0.05) with no NaN/Inf
vectors, and throughput is 35.6 vec/s on the 50-vector probe.

Versus the previous embed baseline (margin 0.283, 93.8 vec/s) the margin
tightens and throughput drops ~2.6x, both consistent with a model swap
behind the same alias. Vectors are unit-normalized and deterministic.
Note: same dimension on a different model means any KB indexed under the
old backing model holds stale vectors and needs a re-embed; the local dev
DB is empty, so the phase 119/162 retrieval battery was not runnable here.

LLM-Generated: true
2026-10-09 12:23:55 -04:00
82 changed files with 7859 additions and 3 deletions
@@ -0,0 +1,47 @@
# Phase 170 — Edit bubble: the editor lives inside the user bubble, "Send" re-asks
**Source:** `TODO.md` L3 — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-10)
**Context:** `frontend/assets/app.js` — `openEditor(wrap, toggleBtn)` (L1210–1357: label + `textarea[rows=3][maxlength=4000]` prefilled through `.value`, `editSaveBtn` "Save" L1238 + cancel, `closeEditor()` L1252 (form.remove + `.is-editing` remove + focus), the submit handler L1265–1345 (in-flight guard, empty guard, replace-in-place, tail truncation + DOM drop, `lastBrainWrap = null`, `bubble.innerHTML = renderMarkdown(text)` + the `.msg-image` re-prepend L1322–1325, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, `closeEditor()` L1337, `runTurn(text, { reask: true })`), the open-tail L1355–1357 (`classList.add("is-editing")` + `body.appendChild(form)` + `ta.focus()`)), `appendEditButton` / `markLastEditable` (L793–871, the phase-159 single-target discipline), `tests/unit/test_frontend_edit_message.py` (house source pins: `editSaveBtn.textContent = "Save"` L348; the `.is-editing` add L365 / remove L387 / ordering L372; the `body.appendChild(form)` pin near L699), `tests/e2e/test_edit_and_resend.py` (the phase-159 suite: `page.click(".edit-form .edit-save")` L337 — class-based, so the label rename touches only its docstrings; the composer's `#send-label` "Send" pins L312/L533 are a different control), `frontend/assets/styles.css` — `.msg.user .bubble.is-editing { display: none; }` (L1411), `.edit-form` (L1412–1420 — its own `--surface` box + border + padding), `.edit-form label` (L1421–1425), `.edit-form textarea` (L1426–1436, `resize: vertical`, `min-height: 2.6rem`), `.edit-form-actions` (L1437), `.edit-save` (L1438–1455, incl. `:disabled { opacity: 0.6; cursor: wait; }` L1454), `.edit-cancel` (L1456–1469), `.edit-error` (L1470–1479), `.bubble` (L501–508), `.msg.user .bubble` (L592–596 — the `--brand` fill + `--bg` text).
## Objective
Clicking **Edit** no longer swaps the user bubble for a shrunken form: the editor lives *inside* the bubble itself (inheriting its box, background, and padding, so it keeps the bubble's dimensions) and the submit button reads **Send** — the action re-sends the question, it does not save a note. Everything else of the phase-159 flow (prefill, empty guard, tail truncation, the `reask` re-ask, one-editor-at-a-time, image survival, the in-flight guards) stays behavior-identical.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The form IS the bubble's content (LOCKED A1).** `openEditor` stops appending the form as a sibling of `.bubble` in `.msg-body` and stops hiding the bubble. It keeps the `.msg-image` child in a closure variable, keeps `bubble.classList.add("is-editing")` (L1355 — the state class survives, the unit pins keep it), and replaces `body.appendChild(form)` (L1356) with the form appended **into** the bubble, the rendered markdown cleared first. The bubble keeps its own `--brand` fill, border, padding, and width — "the same size as the user message bubble" holds by construction (the box IS the box), with zero measurement/matching code. `.msg.user .bubble.is-editing { display: none; }` (styles.css L1411) is **deleted** — the bubble stays visible while the editor is open.
- **The form is transparent.** `.edit-form` drops its own `background` / `border` / `padding` (the bubble provides them) and keeps `display:flex; flex-direction:column; gap: 0.5rem`. The label and the action text take the bubble's `--bg`-family ink (contrast documented in a comment — the B5 text-state rule); the textarea keeps its own readable `--surface` fill + `--ink` text (an edit field on a `--brand` bubble) and gains `width: 100%`. `rows=3` and `maxlength=4000` are untouched — the *outer* box is the bubble's, the textarea is the inner field.
- **Cancel = restore the markdown.** `closeEditor()` keeps exactly three jobs: `form.remove()`, `bubble.classList.remove("is-editing")`, `toggleBtn.focus()` — no content change. The cancel branch (Escape + the Cancel button) gains a restore step: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the mirror of the submit re-render L1322–1325), then `closeEditor()`.
- **Submit ordering.** The submit handler currently re-renders (`bubble.innerHTML = renderMarkdown(text)` L1322) *before* `closeEditor()` (L1337). With the form inside the bubble, `closeEditor()` (which detaches the form) must run **before** the innerHTML re-render; the rest of the handler — truncation, `lastBrainWrap = null` / `leavePartialIndex` reset, persistence, `markLastRetryable()`, the in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- **Label (LOCKED A2).** `editSaveBtn.textContent = "Send"` (app.js L1238) — matching the composer's `#send-label` "Send" and the "Cancel" sibling. The phase-168 "Save settings" button is a different control and keeps its name.
- **NOT touched:** `appendEditButton` / `markLastEditable`, `runTurn`, `retryLastTurn`, `handleSend`, `rememberBrainTurn`, the record-truncation + persistence path, `MAX_QUESTION_CHARS` / `updateCharCount`, the composer (the unsent draft survives the edit), the SSE contract (A15), the shared-chat page, `app/` server code (A10 stateless holds).
## Dependencies
- `159_edit_and_resend_message` (complete) — the inline editor + re-ask flow this phase re-houses and re-labels.
- `166_hide_empty_answer_bubble` (complete) — `.msg .bubble:empty { display: none }` (styles.css L524): while the editor is open the bubble holds the form (never `:empty`), and on cancel the re-rendered markdown is never empty (`min_length=1` pinned client- and server-side) — no interaction, the pin stays green.
## Tasks
1. `01_editor_inside_bubble.md` — `openEditor` builds the form **inside** `.bubble` (transparent form, `display:none` deleted), the cancel re-render, the submit ordering; the unit pins updated.
2. `02_save_label_send.md` — the submit label "Save" → "Send" (app.js L1238), the unit pin, the phase-159 E2E docstring alignment.
3. `03_e2e_edit_bubble.md` — `tests/e2e/test_edit_bubble_sizing.py` (the phase's dedicated Playwright suite, AGENTS rule 4), the regression set, the full gates, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_frontend_edit_message.py` (house source pins, updated in tasks 01/02): the form is appended to the bubble (not `.msg-body`), the `display:none` rule's absence pinned in the CSS, `.is-editing` still added (L365) / removed (L387), the cancel restore (markdown re-render + image re-prepend) and the submit ordering (form detached before the re-render), `editSaveBtn.textContent = "Send"`, and `runTurn` / `retryLastTurn` / `handleSend` byte-unchanged.
- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_edit_bubble_sizing.py`, run **in isolation** — the size/label contract plus the regression flows.
- **Coverage:** **>90%** on `app/` (this phase changes no `app/` code — the gate must stay green, not drop).
- **Lint/types:** `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] Clicking Edit on the last user bubble shows the editor **inside** the bubble: the bubble is visible (computed `display` ≠ `none`), keeps its `--brand` fill, and its box width is unchanged by the edit (the E2E bounding-box pin).
- [ ] The submit button reads "Send"; Cancel / `Escape` restore the rendered question; trim + Send re-asks with the truncated shape and the "Question too long" driver case still resolves.
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), ruff + pyright clean.
- [ ] `uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov` green **in isolation**; regression set green in isolation: `test_edit_and_resend.py`, `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-10, roadmap confirmation)
- **A1 — the editor lives inside the bubble:** "stay the same size as the user message bubble" = the editor occupies the bubble element itself (inherits its box/styling), not a pixel-matched sibling form. `rows=3` + `maxlength=4000` stay.
- **A2 — the label is "Send"** (capital S, matching the composer's `#send-label` "Send" and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button keeps its name.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
@@ -0,0 +1,40 @@
# Task 01 — the editor renders inside the user bubble (same box, no shrink)
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble."
**Story:** n/a
## Objective
`openEditor` renders its form **inside** `.bubble` instead of as a hidden-bubble + small-sibling-form: the editor inherits the bubble's own box, background, and padding, so it keeps the bubble's dimensions by construction. The open / cancel / submit behavior of the phase-159 flow is otherwise unchanged.
## Work
1. `frontend/assets/app.js` — `openEditor` (L1210–1357), three surgical changes:
- **Open:** keep `if (bubble) bubble.classList.add("is-editing");` (L1355); replace `body.appendChild(form);` (L1356) with: grab the `.msg-image` child into a closure variable, clear the bubble's rendered markdown (`bubble.innerHTML = "";` or `bubble.replaceChildren(form)`), append the form **into** the bubble; `ta.focus()` (L1357) stays.
- **Cancel:** `closeEditor()` (L1252–1257) keeps exactly `form.remove()` + `bubble.classList.remove("is-editing")` + `toggleBtn.focus()` — no content change. The cancel paths (the `Escape` keydown branch L1258–1261 and the Cancel-button listener) gain a restore step BEFORE `closeEditor()`: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the exact mirror of the submit re-render L1322–1325).
- **Submit:** reorder — move `closeEditor()` (L1337) to **before** the `if (bubble) { … bubble.innerHTML = renderMarkdown(text); … }` re-render block (L1322–1325), so the form is detached cleanly first. Everything else in the handler — the in-flight guard, the empty guard, `conversation[editIdx].text = text`, the tail truncation + DOM drop, `lastBrainWrap = null` / `leavePartialIndex` reset, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, the second in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- Update the `openEditor` comment block (L1154–1195): the "While open the rendered .bubble is hidden (.is-editing — the question appears ONCE)" sentence becomes the new contract (the form renders inside the bubble; the bubble stays visible; `.is-editing` is the state class); keep the rest of the block.
2. `frontend/assets/styles.css`:
- **Delete** `.msg.user .bubble.is-editing { display: none; }` (L1411) — the bubble stays visible while the editor is open.
- `.edit-form` (L1412–1420): drop the own `background: var(--surface);`, `border: 1px solid var(--brand-soft);`, and `padding: 0.75rem 0.85rem;` (the bubble provides them); keep `display: flex; flex-direction: column; gap: 0.5rem;`.
- `.edit-form label` (L1421–1425): the color becomes the bubble's `--bg`-family ink — on the `--brand` fill, `--bg` is the documented 1.0-against-brand pairing inverted (the user-bubble text pairing, L592–596); document the ratio in the comment (B5: text states it, never color alone); keep `font-size: 0.85rem; font-weight: 600;`.
- `.edit-form textarea` (L1426–1436): add `width: 100%;` (the field fills the bubble's width — the "not tiny" fix); keep `font-size: 0.9rem`, the `--surface` fill + `--ink` text (a readable edit field on the `--brand` bubble), `border`, `resize: vertical`, `min-height: 2.6rem`.
- `.edit-error` (L1470–1479): the `--err-bg` / `--err-ink` pair is documented against `--surface`; on the `--brand` bubble it needs a check — if the pair fails ≥4.5:1 there, scope a `.msg.user .bubble .edit-error` variant on the bubble's own ink pairing (`--bg` text on a `--brand`-darkened fill) and document the ratio; the `role="alert"` behavior is untouched.
- The `:disabled` wait-cursor on `.edit-save` (L1454) is the phase-159 in-flight dim — leave it (task 03 of THIS phase does not touch it; the owner's cursor complaint is the settings page, phase 171).
3. `tests/unit/test_frontend_edit_message.py` — update the pins (house source-pin style):
- the form is appended to the bubble: replace the `body.appendChild(form)` pin (near L699) with the bubble-append pin (e.g. `bubble.replaceChildren(form)` or the innerHTML-clear + append, whichever lands in app.js);
- the `.is-editing` add (L365) / remove (L387) / ordering (L372) pins **stay** (the class is still the state marker);
- new pins: the cancel restore (`renderMarkdown(conversation[editIdx].text)` on cancel + the image re-prepend) and the submit ordering (`closeEditor()` before the `bubble.innerHTML = renderMarkdown(text)` re-render);
- a CSS pin: `.msg.user .bubble.is-editing { display: none; }` is **absent** from `styles.css`;
- `runTurn` / `retryLastTurn` / `handleSend` pins byte-unchanged.
- ASSUMPTION: A1 (phase-locked) — "stay the same size as the user message bubble" is implemented by the editor occupying the bubble element itself (it inherits the bubble's box/styling), not by pixel-matching a sibling form; `rows=3` + `maxlength=4000` stay.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green (`uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov`).
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green).
- Lint/types: `uv run ruff check . && uv run pyright` (the JS is not linted by ruff — the unit pins are the JS gate).
## Completion Criteria
- [ ] In a live browser (or the E2E from task 03): clicking Edit shows the form inside the visible bubble — no `display:none`, no shrunken sibling box.
- [ ] Cancel / `Escape` restore the rendered markdown + the attachment; the record is unchanged.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest` green; ruff + pyright clean.
- [ ] No behavior change in `runTurn` / `retryLastTurn` / `handleSend` (byte-unchanged pins green).
@@ -0,0 +1,25 @@
# Task 02 — the edit-form submit label reads "Send", not "Save"
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a
## Objective
The edit form's submit button reads **Send** — the action re-sends the question (it never persists a standalone note, A10 stateless holds), so "Save" was a lie. The label matches the composer's `#send-label` "Send" and the "Cancel" sibling; nothing else in the flow changes.
## Work
1. `frontend/assets/app.js` L1238 — `editSaveBtn.textContent = "Save";` → `editSaveBtn.textContent = "Send";`. The `edit-save` class, the `type="submit"` wiring, the Ctrl/Cmd+Enter submit, and the whole submit handler are untouched.
2. `tests/unit/test_frontend_edit_message.py` L348 — the pin `assert 'editSaveBtn.textContent = "Save";' in body` → `"Send"`; keep the surrounding pin block (the `editSaveBtn.type` / `editSaveBtn.className` pins) and align the module docstring's "Save/Cancel" phrasing (L15) to "Send/Cancel" where it names the **edit form** (the docstring lines that name the composer's send flow stay — that control was always "Send").
3. `tests/e2e/test_edit_and_resend.py` — the flow drives the edit submit by class (`page.click(".edit-form .edit-save")` L337) — **no code change**. Align the module docstring / comments that name the edit form's "Save" action (L29, L32, L51, L79, L255, L341, L383, L510) to "Send"; the composer's `#send-label` "Send" assertions (L312, L533) are a different control and stay byte-identical.
- ASSUMPTION: A2 (phase-locked) — the label is "Send" (capital S, matching the composer's `#send-label` and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button (a different control) keeps its name.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green.
- E2E (regression, in isolation): `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` — the phase-159 suite now runs the flow through the "Send" label (the click is class-based, so the suite is code-identical; the docstrings are aligned).
- Coverage: **>90%** on `app/` (unchanged).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] The edit form's submit button reads "Send" in the live UI; "Save" appears nowhere in the edit form.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` green in isolation.
- [ ] The phase-168 "Save settings" button (settings view) is byte-untouched.
@@ -0,0 +1,44 @@
# Task 03 — `tests/e2e/test_edit_bubble_sizing.py`: the in-bubble editor, proven in a browser
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble … It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" …"
**Story:** n/a
## Objective
The phase's dedicated Playwright suite (AGENTS rule 4/9): the editor opens **inside** the user bubble with the bubble's dimensions, the submit label reads "Send", and the whole phase-159 flow (trim + re-ask, cancel restore, the "Question too long" driver case) works through the new housing. Then the regression set, the full gates, and the one atomic commit.
## Work
1. `tests/e2e/test_edit_bubble_sizing.py` (new) — copy the module-scoped app fixture from `tests/e2e/test_edit_and_resend.py` verbatim (mock LLM, `BOR_ENVIRONMENT=e2e`, the E2E admin/secret pins) and change exactly: the module docstring (what the suite proves + the Source line `TODO.md:3` — the "in-bubble editor + Send label" contract) and the test list below. DB isolation: this suite writes no `app_settings` / theme rows; the `saved_chats` rows it creates follow the phase-159 suite's convention (no extra teardown needed beyond what that fixture already does — if the fixture lacks a `saved_chats` cleanup, add the same one it would need, mirroring `test_chat_persistence.py`).
2. Tests (names pinned; helpers `login(page, app_url)` from `tests/e2e/auth_helpers.py` where a gate is crossed — the chat is public, so most tests need no login, exactly like the phase-159 suite):
- `test_editor_renders_inside_the_bubble` — send a fixture question, wait for the grounded answer (the mock-LLM settle convention), capture the user bubble's `bounding_box()` + computed `background-color`, click Edit: the form is a **child of the bubble** (`bubble.evaluate("el => el.querySelector('.edit-form') !== null")`), the bubble's computed `display` is not `none`, its `background-color` is unchanged (the `--brand` fill), the textarea prefills the EXACT question, and `.is-editing` is on the bubble.
- `test_editor_box_keeps_the_bubble_dimensions` — the pre-edit bubble `bounding_box()` vs the open-editor bubble: width is ≥ 95 % of the pre-edit width (the "no longer tiny" pin — the old shrunken sibling form failed this by construction) and height is ≥ the pre-edit height (the box grows, never collapses); at 375 px the bubble has no horizontal overflow (§7.1 reading column).
- `test_submit_label_reads_send` — `.edit-save`'s text is "Send" (and not "Save"); `.edit-cancel`'s is "Cancel".
- `test_trim_and_send_reasks_in_place` — the phase-159 flow through the new label: append " in one line", click `.edit-form .edit-save` (the "Send" button): the truncated shape `[user(edited), brain]` (the `bor.chat.v1` localStorage pin, the phase-159 convention), the re-ask streams the grounded answer, no duplicated question, the UNSSENT composer draft survives.
- `test_cancel_restores_the_bubble` — open the editor, Cancel: the rendered markdown is back (the question text is visible in the bubble, the form is gone, `.is-editing` off), the `conversation` record is byte-identical; a second pass with `Escape` (a fresh turn) pins the same restore through the keydown path.
- `test_failed_turn_still_editable` — the phase-114 driver case (the 4000+ char question → the "Question too long — trim it and re-ask" error frame, the phase-120 failed record): the editor opens inside the bubble, trimming the question to the cap + "Send" streams the grounded answer (the failed record is replaced).
3. **Regression set, all in isolation** (`--no-cov`): `test_edit_and_resend.py` (the phase-159 suite — now running against the in-bubble editor + the "Send" label), `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
4. **Full gates** (AGENTS rule 9, all green before the commit):
```bash
uv run pytest
uv run pytest --cov=app --cov-report=term-missing # >90% on app/
uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov
uv run ruff check . && uv run pyright
```
5. **Docs & close-out:** confirm `README.md`'s edit-the-last-question note (if any names the "Save" label or the hidden-bubble behavior — phase 159's README section, grep `edit` there) is aligned to the in-bubble editor + "Send"; move the phase directory:
```bash
git mv .agents/phases/todo/170_edit_bubble_sizing .agents/phases/complete/170_edit_bubble_sizing
```
and commit (AGENTS rule 8, `--no-gpg-sign`):
```bash
git add frontend/ tests/ README.md .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
## Testing & Quality
- The suite passes **in isolation** (`-v --no-cov`).
- Unit pins already exist from tasks 01/02 — this file adds the browser contract only; if it needs a behavior the UI lacks, fix the UI in this task and say so in the commit body.
- Coverage: **>90%** on `app/` after everything (the `--cov` runs exclude E2E by the repo's config; the gate is the unit+integration number).
## Completion Criteria
- [ ] `test_editor_box_keeps_the_bubble_dimensions` is green: the open editor's bubble keeps the pre-edit width (≥ 95 %) and never collapses.
- [ ] The "Send" label, the in-bubble housing, the cancel restore, and the failed-turn driver case are all green in isolation.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] Phase directory moved to `.agents/phases/complete/170_edit_bubble_sizing/` and one atomic `--no-gpg-sign` Conventional Commit exists (verify with `git log -1 --stat`).
@@ -0,0 +1,46 @@
# Phase 171 — Admin settings page: row rhythm, distinct group bands, a properly disabled Save
**Source:** `TODO.md` L4 — "In the admin settings page: The search settings text box overlaps settings table. The settings category headers are not differentiated from the settings themselves, making them confusing at a glance. The save settings and clear all overrides are smushed right up against the settings table. The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-10)
**Context:** `frontend/index.html` — `#view-settings` (L1499), `#settings-content` (L1535, a plain block — its children `#settings-disabled`, `#settings-status`, `#settings-error`, `.settings-search` (L1562), the `.table-wrap` card (L1576, `#settings-tbody` created by settings.js), and `.settings-actions` (L1607: `#settings-save` disabled by default + `#settings-clear-all` + `#settings-dirty`) stack with **zero vertical rhythm** — the root of the "overlap" and the "smushed" complaints), `frontend/assets/settings.js` (637 lines — the group-row render L319–335: `tr.settings-group-row` + `th[colspan=4][scope=colgroup]` with the group name, the client-side search filter, the §7.4 Save lifecycle), `frontend/assets/styles.css` — `.settings-shell` (L4660–4665: `display:flex; flex-direction:column; gap: 1.25rem;`), `.settings-group-row th` (L4684–4690: `background: var(--surface)` — the **same** fill as the `.table-wrap` card (L2825–2831) → the band is effectively invisible, the "not differentiated" complaint), the sticky-th tint rule `.table-wrap table thead th { background: var(--brand-soft); }` (L2841–2848, the solid-tint precedent), `.settings-search` (L4780–4786), `.settings-actions` (L4804–4809), `#settings-save` (L4810–4824, incl. `:disabled { opacity: 0.6; cursor: wait; }` L4824 — the loading-cursor complaint), `.settings-reset:disabled { opacity: 0.5; cursor: default; }` (L4775 — the house idiom), the mobile media queries (L6396–6400: `.settings-search` / `.settings-actions` `flex-direction: column`, `#settings-save` `width: 100%`), `tests/unit/test_settings_frontend_view.py` (the phase-168 static pins, incl. the L561 media-query pin), `tests/e2e/test_admin_settings_panel.py` (the phase-168 suite — the regression set).
## Objective
The four layout complaints on the Settings page — the search box overlapping the table card, the group headers indistinguishable from the rows, the Save / Clear row smushed against the table, and the disabled Save showing a loading cursor — are fixed with **CSS only** (plus the pin updates): one rhythm rule, one band rule, one cursor value. The phase-168 contract (the gate, the catalogue, the §7.4 Save lifecycle, the API, the bounded scroll + sticky header) stays behavior-identical — no `app/` code, no JS logic change.
## Design (shared by all tasks — the executor reads this, not the chat)
- **D1 — the rhythm (task 01).** `#settings-content` becomes `display: flex; flex-direction: column; gap: 1.25rem;` — the **same** token `.settings-shell` already uses (L4664). The children (the disabled banner, the status line, the error line, the search row, the `.table-wrap` card, the `.settings-actions` row) then stack with one uniform gap: the search box clears the table card (the "overlap" dies) and the Save / Clear row is separated from the table (the "smushed" dies) in the same change. The `min-height` rules on `#settings-status` / `#settings-error` (L4849–4862) already reserve their lines — the gap is additive and uniform, no per-element margins are introduced.
- **D2 — the band (task 02).** `.settings-group-row th` gets a band that is distinct from the row cells at a glance: `background: var(--brand-soft)` — the **same solid tint** the sticky `thead` rule already uses (L2847; B3: no new palette variable), `font-weight: 700` (the 0.82rem / uppercase / letter-spacing stay), and `border-top: 1px solid var(--line)` so a band never merges with the card's top edge or a previous band. The row cells stay on the card's `--surface`. The group text stays `--ink` on `--brand-soft` — the pairing the sticky-th rule already documents. Static, text-only, no animation (nothing for `prefers-reduced-motion` to still).
- **D3 — the cursor (task 03).** `#settings-save:disabled` → `cursor: default` — the `.settings-reset:disabled` idiom (L4775); "just disabled", per the owner. The §7.4 in-flight state is the **dimmed** `:disabled` (`opacity: 0.6` stays) + `aria-busy` — the `cursor: wait` was never part of the contract; it was the "loading cursor on hover" the owner saw.
- **NOT touched:** `settings.js` (the render / filter / save / reset / clear-all logic), `app/api/admin_settings.py`, the sign-in gate, the nav reveal, the `.table-wrap` bounded-scroll + sticky-th contract (L2825–2848), the `#settings-status` / `#settings-error` regions, the `restart required` flag, the mobile media queries (L6396–6400 — they compose with the new gap and stay byte-identical), `app/` server code.
## Dependencies
- `168_admin_settings_panel` (in flight — the code + the E2E suite are committed; only its directory move + final commit are pending) — the Settings view this phase polishes. Note: the abandoned `169` settings status card never landed — there is nothing to coordinate with.
## Tasks
1. `01_content_vertical_rhythm.md` — `#settings-content` becomes a flex column with the shell's `1.25rem` gap (fixes the search/table overlap **and** the smushed action row); the pin added.
2. `02_group_band_distinction.md` — `.settings-group-row th` gets the `--brand-soft` band + 700 weight + top hairline, so groups read at a glance.
3. `03_disabled_save_cursor.md` — `#settings-save:disabled` → `cursor: default` (the house idiom), killing the loading cursor.
4. `04_e2e_settings_layout.md` — `tests/e2e/test_admin_settings_layout.py` (the phase's dedicated Playwright suite, AGENTS rule 4), the regression set, the full gates, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_settings_frontend_view.py` (the phase-168 static pin set, extended in tasks 01/02/03): the `#settings-content` flex-column + gap rule, the band's `--brand-soft` background + weight + top border, `#settings-save:disabled` `cursor: default` (and `cursor: wait` absent from the settings rules), plus the existing pins (the no-CDN pin, the `router.js` mapping, the media-query pin L561) staying green.
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_admin_settings_layout.py`, run **in isolation** — the four layout contracts, proven in a browser.
- **Coverage:** **>90%** on `app/` (this phase changes no `app/` code — the gate must stay green, not drop).
- **Lint/types:** `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] The search box and the table card do not intersect; the Save / Clear row is visibly separated from the table (the E2E bounding-box pins, at 1440 px and 375 px).
- [ ] A settings group band is visually distinct from its rows (computed background ≠ the row cells' background; weight 700) and no band merges with the card edge.
- [ ] A disabled **Save settings** shows `cursor: default` (not `wait`); the §7.4 dimmed-`aria-busy` in-flight state is intact.
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), ruff + pyright clean.
- [ ] `uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov` green **in isolation**; `test_admin_settings_panel.py` (phase 168) + `test_wide_desktop_column.py` green in isolation.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-10, roadmap confirmation)
- **A3 — the band tint is `--brand-soft`** (the solid tint the sticky `thead` already uses — B3: no new palette variable) + a `--line` top hairline + weight 700; static, no animation.
- **A4 — the rhythm reuses the shell's existing `gap: 1.25rem`** — no new spacing value; the existing mobile media queries (L6396–6400) keep working unchanged.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): settings page layout — row rhythm, distinct group bands, a properly disabled Save"
```
@@ -0,0 +1,31 @@
# Task 01 — `#settings-content` gets the shell's vertical rhythm (overlap + smushing die)
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The search settings text box overlaps settings table. … The save settings and clear all overrides are smushed right up against the settings table."
**Story:** n/a
## Objective
The `#settings-content` block becomes a flex column with the shell's existing `gap: 1.25rem`, so its children — the disabled banner, the status line, the error line, the search row, the `.table-wrap` card, the `.settings-actions` row — stack with one uniform gap. One change kills both complaints: the search box clears the table card (the "overlap") and the Save / Clear row separates from the table (the "smushed").
## Work
1. `frontend/assets/styles.css` — add ONE rule (place it directly after the `.settings-shell` rule, L4660–4665, with a comment citing the phase-171 task + TODO L4):
```css
#settings-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
```
No per-element margins anywhere; the `min-height` rules on `#settings-status` / `#settings-error` (L4849–4862) stay (they reserve the lines; the gap is additive and uniform). Verify by reading, not by adding: `.settings-search` (L4780–4786) and `.settings-actions` (L4804–4809) get **no** new margins — the gap is the only spacing source.
2. `tests/unit/test_settings_frontend_view.py` — add the house-style CSS pin: the `#settings-content` rule exists with `display: flex`, `flex-direction: column`, and `gap: 1.25rem` (the same pin family as the L561 media-query pin — parse `styles.css`, assert the three declarations in one rule block).
- ASSUMPTION: A4 (phase-locked) — the rhythm reuses the shell's existing `gap: 1.25rem` token; no new spacing value; the existing mobile media queries (styles.css L6396–6400: `.settings-search` / `.settings-actions` `flex-direction: column; align-items: stretch;` + `#settings-save { width: 100%; }`) keep working unchanged.
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green (`uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov`).
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): the search input's box and the `.table-wrap` card's box do not touch — a visible, uniform gap between them, and the same gap above the `.settings-actions` row.
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; `uv run pytest` green; ruff + pyright clean.
- [ ] No per-element margin was added (the diff touches exactly one CSS rule + the pin); the mobile stacking (L6396–6400) is byte-unchanged.
@@ -0,0 +1,28 @@
# Task 02 — the group bands are distinct from the rows at a glance
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The settings category headers are not differentiated from the settings themselves, making them confusing at a glance."
**Story:** n/a
## Objective
`.settings-group-row th` — today `background: var(--surface)`, the **same** fill as the `.table-wrap` card, i.e. invisible — becomes a band that reads as a category: the `--brand-soft` tint (the solid tint the sticky `thead` rule already uses), weight 700, and a `--line` top hairline so a band never merges with the card edge or a previous band.
## Work
1. `frontend/assets/styles.css` — `.settings-group-row th` (L4684–4690), three value changes in the existing rule (no new selector):
- `background: var(--surface);` → `background: var(--brand-soft);` (B3: no new palette variable — the exact tint `.table-wrap table thead th` already documents at L2841–2848);
- add `font-weight: 700;` (the `font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em;` stay);
- add `border-top: 1px solid var(--line);` (the rule's existing `border-bottom` from the `.settings-table th` family, L4676–4681, stays — a band is delimited above AND below, and can never merge with the card's top edge or the band before it);
- update the contrast comment (L4683): the text stays `--ink` on `--brand-soft` — the pairing the sticky-th rule already documents (the existing "15.3:1, AA" comment applies to `--surface`; recompute / restate the ratio on `--brand-soft` in the comment — the `:root` `--brand-soft` token carries its own documented pairing).
2. `tests/unit/test_settings_frontend_view.py` — add the pin: the `.settings-group-row th` rule carries `background: var(--brand-soft)`, `font-weight: 700`, and `border-top: 1px solid var(--line)` (same pin family as task 01's).
- ASSUMPTION: A3 (phase-locked) — the band = `--brand-soft` (the sticky-th tint, no new palette variable) + `--line` top hairline + weight 700; static, text-only, no animation (nothing for `prefers-reduced-motion` to still).
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green.
- Coverage: **>90%** on `app/` (unchanged code).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): each group band is visibly tinted (`--brand-soft`), bold, and hairline-delimited above; the row cells stay on the card's `--surface` — the two are distinguishable at a glance.
- [ ] No band merges with the card's top edge or the band above it (the top hairline is painted).
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; ruff + pyright clean.
- [ ] `settings.js` is byte-unchanged (the band is pure CSS — the group-row render L319–335 is untouched).
@@ -0,0 +1,23 @@
# Task 03 — a disabled Save settings is just disabled (no loading cursor)
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a
## Objective
`#settings-save:disabled` stops painting `cursor: wait` (the loading cursor the owner saw on hover) and takes `cursor: default` — the house idiom `.settings-reset:disabled` already uses. "Just disabled": the §7.4 in-flight state stays the **dimmed** `:disabled` (`opacity: 0.6`) + `aria-busy`; the cursor was never part of the contract.
## Work
1. `frontend/assets/styles.css` L4824 — `#settings-save:disabled { opacity: 0.6; cursor: wait; }` → `#settings-save:disabled { opacity: 0.6; cursor: default; }`. Keep `opacity: 0.6` (the §7.4 dim) and the `#settings-save:hover:not(:disabled)` rule (L4823) byte-unchanged. Update the adjacent comment block (L4809–4810, "the §7.4 in-flight state is the dimmed :disabled + aria-busy") to state the cursor contract explicitly: `cursor: default` when disabled (the `.settings-reset:disabled` L4775 idiom) — the wait cursor was never part of §7.4.
2. `tests/unit/test_settings_frontend_view.py` — add the pin: `#settings-save:disabled` carries `cursor: default`, and `cursor: wait` is **absent** from the settings-page rules (a negative grep over the `.settings*` / `#settings-*` rule blocks — the house negative-pin idiom). Note: `.edit-save:disabled` (styles.css L1454) keeps its own `cursor: wait` — it is the chat edit form's in-flight state (phase 159/170), a different control; the negative pin is scoped to the settings rules only, and the test must not fail on the `.edit-save` rule.
- ASSUMPTION: none — the owner's words fix the target ("just being disabled"); the idiom choice (`default`, mirroring L4775) is the house convention, not a guess.
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green.
- Coverage: **>90%** on `app/` (unchanged code).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): an untouched **Save settings** (no dirty rows) is `disabled` and its computed `cursor` is `default` — hovering shows no wait/loading cursor; after a row is edited the button enables and its cursor is `pointer` again (the §7.4 lifecycle, phase 168, intact).
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; ruff + pyright clean.
- [ ] The diff is exactly one CSS value + one comment + the pin; `settings.js` and `app/` are byte-unchanged.
@@ -0,0 +1,43 @@
# Task 04 — `tests/e2e/test_admin_settings_layout.py`: the four layout contracts, proven in a browser
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "In the admin settings page: The search settings text box overlaps settings table. The settings category headers are not differentiated … The save settings and clear all overrides are smushed right up against the settings table. The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a
## Objective
The phase's dedicated Playwright suite (AGENTS rule 4/9): the admin signs in, and the four layout contracts hold in a real browser — the search row clears the table card, the action row is gapped from the table, the group bands are distinct, and a disabled Save is just disabled. Then the regression set, the full gates, and the one atomic commit.
## Work
1. `tests/e2e/test_admin_settings_layout.py` (new) — copy the module-scoped app fixture from `tests/e2e/test_admin_settings_panel.py` verbatim (the mock LLM, `BOR_ENVIRONMENT=e2e`, `BOR_STATIC_DIR`, the E2E admin password/secret, the leak guards, the module's `BOR_SETTINGS_DB=1` opt-in + the `TRUNCATE app_settings` before/after — this suite never writes overrides, so the truncation is belt-and-braces, the same idiom) and change exactly: the module docstring (what the suite proves + the Source line `TODO.md:4` — the four layout contracts) and the test list below.
2. Tests (names pinned; `login(page, app_url)` from `tests/e2e/auth_helpers.py`; the suite drives the admin gate exactly like the phase-168 suite):
- `test_search_row_is_clear_of_the_table` — at 1440 px and 375 px: `#settings-filter`'s `bounding_box()` and the `.table-wrap` card's box do not intersect (the vertical gap between the search row's bottom and the card's top is > 0), and the card keeps its bounded scroll + sticky `thead` (the computed `position: sticky` on the `thead th` — the phase-156/168 contract intact under the new rhythm).
- `test_actions_row_is_gapped_from_the_table` — at 1440 px and 375 px: `.settings-actions`'s box does not intersect the `.table-wrap` card's box (the "smushed" pin — the gap between the card's bottom and the action row's top is > 0); `#settings-save` and `#settings-clear-all` are both reachable (no clipping at either viewport).
- `test_group_bands_are_distinct_from_the_rows` — after `login()`: at least the `Retrieval & honesty` and `LLM (aipi)` `tr.settings-group-row` bands render; a band's computed `background-color` ≠ the `.table-wrap` card's computed background (the `--surface` row-cell fill) — the "not differentiated" pin — and the band's computed `font-weight` is ≥ 700; a band's computed `border-top-width` is ≥ 1 px (the hairline — no merge with the card edge or the previous band).
- `test_disabled_save_is_just_disabled` — before any edit: `#settings-save` is `disabled` and its computed `cursor` is `default` (NOT `wait` — the loading-cursor pin); edit one row (a `type=search` filter + a value change, the phase-168 dirty-row flow): the button enables (computed `cursor` `pointer`) and dims back to `default` after a **Save settings** settles (the §7.4 lifecycle, phase 168, intact under the new cursor).
- `test_settings_page_layout_and_accessibility` — no horizontal page overflow at 375 px and 1440 px (§7.1: the table is ≥80 % of the container, not a skinny list); keyboard tab order still reaches filter → first row input → **Save settings** → **Clear all overrides** (a `page.keyboard` walk asserting focus lands on the real controls — the new rhythm must not have broken the phase-168 keyboard contract); zero external requests during the whole test (`page.on("request")` — rule 6).
3. **Regression set, all in isolation** (`--no-cov`): `test_admin_settings_panel.py` (the phase-168 suite — the gate, the save-live contract, the masking, the reset / clear-all, now under the new layout), `test_wide_desktop_column.py` (the 72rem container contract, the Settings view included).
4. **Full gates** (AGENTS rule 9, all green before the commit):
```bash
uv run pytest
uv run pytest --cov=app --cov-report=term-missing # >90% on app/
uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov
uv run ruff check . && uv run pyright
```
5. **Docs & close-out:** confirm `README.md`'s Settings page section (phase 168's addition) needs no layout wording change (it describes the contract, not the pixels — grep `Settings` there); move the phase directory:
```bash
git mv .agents/phases/todo/171_admin_settings_layout .agents/phases/complete/171_admin_settings_layout
```
and commit (AGENTS rule 8, `--no-gpg-sign`):
```bash
git add frontend/ tests/ README.md .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): settings page layout — row rhythm, distinct group bands, a properly disabled Save"
```
## Testing & Quality
- The suite passes **in isolation** (`-v --no-cov`) and leaves `app_settings` empty (the `TRUNCATE` idiom — a leftover row is the cross-suite poison phase-168 D6 exists to prevent).
- The pins already exist from tasks 01/02/03 — this file adds the browser contract only; if it needs a behavior the CSS lacks, fix the CSS in this task and say so in the commit body.
- Coverage: **>90%** on `app/` after everything (the `--cov` runs exclude E2E by the repo's config; the gate is the unit+integration number).
## Completion Criteria
- [ ] All four layout pins are green in isolation: search clear of the table, action row gapped, bands distinct (tint + weight + hairline), disabled Save `cursor: default`.
- [ ] `app_settings` is empty after the suite and the phase-168 regression suite is green in isolation.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] Phase directory moved to `.agents/phases/complete/171_admin_settings_layout/` and one atomic `--no-gpg-sign` Conventional Commit exists (verify with `git log -1 --stat`).
@@ -0,0 +1,13 @@
**Phase 168 — Admin Settings panel — final verification: PASS**
1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 108 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 86 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6392 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
Task 01 complete. Report:
**Implemented**
- `app/models.py`: `AppSetting` model appended after `FolderSummary` — `key` (bare field name, VARCHAR(100) PK), `value` (env-native string, TEXT NOT NULL), `updated_at` (TIMESTAMPTZ, server `now()`); module docstring table list updated. `func` was already imported — no new imports.
- `alembic/versions/0028_app_settings.py` (revision 0028 ← 0027): `create_table` upgrade, `drop_table` downgrade with the A13 comment (0027-style docstring).
- `tests/integration/test_migration_0028.py`: 0017-harness, 6 tests — column/PK contract, 0027 schema survives, value round-trips, duplicate-key `IntegrityError`, NULL-value rejection, A13 downgrade/round-trip.
- `.env.example`: top comment block — DB-over-env precedence, panel never writes the file, `BOR_SETTINGS_DB=0` kill switch in container env (not committed here).
**Gates**
- `uv run alembic upgrade head` → 0028 applied; `downgrade 0027` → table gone; re-upgrade clean
- `uv run pytest tests/integration/test_migration_0028.py -v` → 6 passed
- `uv run pytest --cov=app` (3,986 tests) → exit 0, 100% green; `app/` coverage **99%** (>90%)
- `uv run ruff check .` → all passed; `uv run pyright` → 0 errors
**Docs** — `.env.example` updated (per task); no README/AGENTS.md impact (no API/CLI/user-visible change yet — the API note lands in task 04). No behavior change: nothing reads `app_settings` yet (task 02).
**Decisions** — migration create date 2026-10-09 (phase date); module-docstring bullet added alongside the class for house consistency.
**Next pending task** — `02_settings_overlay_resolver.md`
@@ -0,0 +1,130 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 280 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 73 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 505 0 100%
--------------------------------------------------
TOTAL 6107 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
All green. Task 02 complete.
## Report
**Implemented (phase 168, task 02):**
- `app/core/settings_catalog.py` — `SettingMeta` + complete 75-field `CATALOG` (14 groups in the prescribed display order), `GROUPS`, derived `SECRETS`/`RESTART_REQUIRED`/`NON_OVERRIDABLE`, `env_var()`
- `app/core/settings_overlay.py` — `resolve_settings()` (env base + kill switch + DB rows, never calls back into config), `enabled()` per-call `BOR_SETTINGS_DB` read, `load_overrides()` (lazy `NullPool` engine, fail-soft: DEBUG once per process for missing table, WARNING once naming the exception class), `merge()` (per-key skip: orphan/non-overridable/malformed-JSON/validator-drop with cross-field + env-collision fallback, each logged `settings override ignored key=… reason=…`), `register_invalidator`/`run_invalidators` (raising hook logged, never propagated), `reset_state_for_tests()`
- `app/config.py` — `get_settings()` → cached lazy delegate to the overlay (`@lru_cache` + `cache_clear()` surface intact), new `invalidate_settings()`
- `app/main.py` — post-`ensure_admin_configured` prime: `invalidate_settings()` + `get_settings()` + one boot line (`settings overrides loaded count=N` / `source=env-only disabled`)
- Poison guards: `tests/conftest.py` (`setdefault BOR_SETTINGS_DB=0`), `tests/e2e/conftest.py` (hard pin beside `BOR_PDF_PICTORIAL`)
- Unit pins: `test_settings_catalog.py` (completeness both directions, closed groups, exact SECRET/NON_OVERRIDABLE/RESTART sets, label/`env_var` pins) + `test_settings_overlay.py` (enabled matrix, `_coerce`, merge happy/skip/invalid/cross-field/env-collision/bool, load failure paths, invalidator registry, recursion-guard + no-`.env`-write source pins)
**Gates:** `uv run pytest` → 4017 passed (run twice — empty `app_settings` and with garbage rows, both green: poison guard proven) · `--cov=app` → 99% (>90%; both new modules 100%) · `ruff check .` clean · `pyright` 0 errors · live-DB checks: DB row wins on / env wins with `BOR_SETTINGS_DB=0` / `database_url`+`embedding_dim` rows skipped+logged · boot log verified both variants
**Docs:** README Configuration reference — added the DB-over-env precedence paragraph + `BOR_SETTINGS_DB` switch (`.env.example` block landed in task 01). AGENTS.md: no doc impact (it documents no env vars).
**Decisions:** standalone `BOR_RELEVANCE_THRESHOLD=0.11` raises the pre-existing cross-field validator (floor 0.35 > 0.11) — verified `Settings()` pre-phase raises identically; with consistent floors it prints 0.11. The validator-blames-env-field case (env floor above a DB threshold) resolves by dropping the colliding override row, not the env value — terminates, rest of the table stands (A5).
**Next pending task:** `03_live_apply_on_save.md`
@@ -0,0 +1,132 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 81 2 98%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 505 0 100%
--------------------------------------------------
TOTAL 6249 19 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All gates green. Task complete.
## Report
**Implemented (phase 168, task 03 — live apply on Save):**
- `app/api/chat.py`: `reset_runtime_caches()` below `get_llm()` — nulls the `_llm` / `_chat_semaphore` settings snapshots (in-flight-turn contract in the docstring; `_chat_active` untouched)
- `app/main.py`: `create_app()` registers `register_invalidator(reset_runtime_caches)` right after the task-02 prime line, with a one-time `settings invalidator registered name=chat_runtime_caches` log line (composition root — `app/core/` stays import-clean of `app/api/`)
- `tests/unit/test_settings_live_apply.py` (9 tests): null/reset pins, `get_llm()` singleton + rebuild, semaphore follows `BOR_CHAT_MAX_CONCURRENT`, the `create_app` wiring source pin, `app/core`→no-`app/api` grep pin, `invalidate_settings()` hook-run + cache-clear proof, raising-hook survival
- `tests/integration/test_admin_settings_live.py` (6 tests, `db`-fixture skip idiom): `relevance_threshold` flip proven through `plan_turn` (HIGH→LOW, no restart), `LLMClient` rebuild (`llm_chat_model` + `llm_timeout` → `AsyncOpenAI` base_url + connect timeout), semaphore cap re-pointing, kill-switch→env-back, `import_extension_set` overlay reach; teardown leaves the table empty (verified: 0 rows)
- `tests/unit/test_main.py`: added the switch-ON boot-prime test (closes the 2 previously-uncovered `app/main.py` lines — a task-02 gap)
**Results:** `uv run pytest` → 4033 passed, 0 failed · `uv run pytest --cov=app --cov-report=term-missing` → 99% TOTAL (>90% gate; `app/main.py` 100%, `settings_overlay.py` 100%) · `uv run ruff check .` → all passed · `uv run pyright` → 0 errors · chat pins (`test_chat_*`, `test_chat_api.py`, `test_chat_concurrency.py`) pass untouched · integration suite green (teardown leaves nothing behind)
**Docs:** `.env.example` — added the "restart required" sentence to the phase-168 comment block (per task step 5); `README.md` — matching one-sentence supplement to the phase-168 paragraph; no `AGENTS.md` impact (no command/convention change)
**Decisions:** openai normalizes `base_url` with a trailing slash — the pin compares against `rstrip("/") + "/"`; `AsyncOpenAI._client` is a wrapper (`.timeout` still delegates to httpx, `connect == 7.0` asserted); the integration fixture re-wires the reset hook after `reset_state_for_tests()` since a prior unit suite's reset would have cleared the `app.main`-import registration
**Next pending task:** `04_admin_settings_api.md`
@@ -0,0 +1,133 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 84 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 505 0 100%
--------------------------------------------------
TOTAL 6255 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All green. Final report:
**Implemented (task 04 — `/api/admin/settings`):**
- `app/schemas.py`: `AdminSettingItem` / `AdminSettingsTotals` / `AdminSettingsOut` / `AdminSettingIn` / `AdminSettingsCleared` (phase-168 docstring style)
- `app/api/admin_settings.py`: router-wide `require_admin` (anon + token user 403); GET catalogue in `GROUPS` order with `db`/`env`/`default` badges + `overlay_enabled`; PUT with 404/422 guards (A3 keys, blank secret) + full-candidate validation through the overlay's `_coerce` + real `Settings` validators (422 names the field, nothing written); `pg_insert` upsert; `DELETE /{key}` idempotent reset; `DELETE ""` clear-all; value-free log lines; `invalidate_settings()` on every write
- `app/main.py`: router included after `ui_settings_router` with the house comment
- Tests: `tests/integration/test_admin_settings_api.py` (11 tests: auth matrix, shape/order, write→live via `get_settings()` + `/api/config`, 404/422/invalid-JSON/cross-field, masking (raw-text assert), reset/clear-all, kill switch, <64 KB budget, caplog log pin) + `tests/unit/test_admin_settings_api.py` (8 tests: `_display`/`_field_type`, masked/default/db branches, `.env`-never-written + no-shell static pins)
**Results (all 100% green):**
- `uv run pytest --cov=app` → 4052 passed, app/ coverage 99% (>90% gate); `app/api/admin_settings.py` 100%
- `uv run pytest tests/integration/test_admin_settings_api.py -v` → 11 passed; integration suite run 3× incl. once with a deliberate poison `app_settings` row — teardown left 0 rows
- `uv run ruff check .` clean; `uv run pyright` clean (5 initial errors fixed: Literal annotation, `RETURNING` instead of `rowcount`)
**Docs:** README Configuration reference — made the precedence explicit (`app_settings` row → `BOR_*` env → code default) + documented the `/api/admin/settings` routes & write-only secrets; `.env.example` already complete from tasks 01/03; no AGENTS.md impact (it documents operating rules, not endpoints)
**Decisions:** `source="db"` requires the overlay switch ON (kill switch off → badge falls back to env/default, `overlay_enabled=false` banner); unknown DELETE key → 404 like PUT (idempotency is row-absent only); `totals.overridden` counts in-effect rows
**Next pending task:** `.agents/phases/todo/168_admin_settings_panel/05_settings_view_ui.md`
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 108 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 86 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6392 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
All green. Final report:
**Phase 168 task 05 (Settings view) — complete** (work was largely present in the working tree from a prior partial run; I verified every item, fixed two defects, and re-verified end-to-end)
- `#view-settings` in `index.html` (72rem container, gate, kill-switch banner, status/alert regions, search, `.table-wrap` catalogue, Save/Clear-all/dirty-hint action row) + `#nav-settings` (ship-hidden) on all 4 Theme-bearing pages
- `router.js` (VIEW/VIEW_PATH/VIEW_MODULES + title/description), `main.py` shell route, `caching.HTML_PAGES`, `header.js` admin-keyed reveal
- `settings.js` (gate-first, createElement/textContent only, dirty Map, K4 stop-at-first-failure, two-step confirm, client-side filter) + `styles.css` phase-168 block (no new `:root` tokens) + `test_settings_frontend_view.py` (13 pins)
- **Fixes:** ruff W291 trailing-whitespace and pyright `None.group()` access in `test_settings_frontend_view.py`
- Verified live: `/settings.html` 200 + `no-cache` + `?v=` rewrite; anon API 403; admin GET → 75 items, 4 masked secrets, zero plaintext; PUT `input_placeholder` → `GET /api/config` shows the override **without restart**; value-free `settings action=…` log line; DELETE → env/default wins
- `uv run pytest --cov=app`: **4065 passed, 99% coverage** (>90%); `uv run ruff check .`: clean; `uv run pyright`: 0 errors
- Docs: README updated (Settings page section + DB-over-env env note); AGENTS.md — no doc impact (no page inventory; rules are generic)
- No live-infra changes (dev-container DB only)
**Next pending task:** `168_admin_settings_panel/06_e2e_settings_panel.md` (the phase's Playwright suite + regression set + atomic commit)
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 108 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 86 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6392 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,12 @@
All gates green and the working tree is exactly my three changes (plus prior-task artifacts the harness owns). Final report:
**Phase 168 · Task 06 — E2E for the Admin Settings panel: COMPLETE**
1. **New suite** `tests/e2e/test_admin_settings_panel.py` — **12/12 pass in isolation** (`uv run pytest tests/e2e/test_admin_settings_panel.py -v --no-cov`, Postgres up).
2. **Contract proven in a real browser**: admin saves a `.env`-only setting (75 `BOR_*` fields, `app_settings`) → **the same running app serves the new value on the next real chat turn — no restart, no `.env` write** (`.env` mtime guarded); the gate (anon 403 + no data leak, token users never see the panel), 422 refusal with the alert, the K4 failed-batch error fix (a real `settings.js` defect — the failed save's re-`GET` was clearing its own error — now fixed: re-GET first, *then* the error), Reset, Clear All, the kill-switch banner, secret rows masked, source badges, and a full keyboard walk (skip-link → … → first row control).
3. **Live-bar proof** uses the task's sanctioned proxy question (cosine 0.25, zero FTS hits) with bars derived from the measured `top_score`; the server's per-turn log line (`threshold=0.20 deflected=False` / `threshold=0.27 deflected=True`) corroborates each flip.
4. **Regression (each in isolation)**: `test_admin_theme_tab` 5 ✓ · `test_api_tokens` 9 ✓ · `test_configurable_brand` 5 ✓ · `test_global_tuning` 6 ✓ · `test_admin_auth` 6 ✓ · `test_cache_busting` 4 ✓ · `test_wide_desktop_column` 3 ✓.
5. **Full suite**: 4065 passed · **coverage 99%** (>90% gate) · ruff clean · pyright 0 errors.
6. **Byte audit** (`scripts/api_payload_audit.py`, now includes the catalogue): `/api/admin/settings` = 23.6 KB identity / 3.3 KB gzip — **ok** (256 KB budget); ` :: api ` log line observed live for the PUT/DELETE routes; the write-side log line carries key + status only (A4 — never the value).
7. **DB clean**: `app_settings` 0 rows, `ui_settings` 0, no `e2e-*` tokens (module autouse fixture TRUNCATEs before + after every test).
8. Working tree only — no git operations; README/AGENTS already document the panel + audit (no changes needed).
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 108 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 285 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 86 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6392 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+357
View File
@@ -0,0 +1,357 @@
---
name: migrate-embed-model
description: Walks a developer through migrating Brain of Reese to a new embedding model — probe and benchmark the new model, MEASURE whether the stored 768-dim vectors are still comparable (cosine-agreement verdict), back up and run the migration on production (re-embed in place, or stamp only), then re-tune the honesty/citation thresholds and smoke-test them against the retrieval battery. Use when the user asks to change, upgrade, switch or roll back the embedding model (e.g. "upgrade the embed model to embedding gemma 2", "switch BOR to a new embedding model", "can I change the embed model without re-importing?", "re-embed the knowledge base"). Same-dimension swaps are the dangerous case — nothing errors, retrieval just degrades silently. Does NOT apply when the output DIMENSION changes (that is the drop-and-recreate path in README.md) and does not test chat/summary models (see test-chat-model / test-summary-model).
---
# Migrate the Embedding Model
An embedding-model change that keeps the dimension (768 → 768) produces **no
error anywhere** in Brain of Reese: `chunks.embedding` is fixed at
`BOR_EMBEDDING_DIM`, `EmbeddingDimensionError` (`app/rag/llm.py`) only fires on
a dimension mismatch, and a re-sync re-embeds **nothing** because the
`documents.file_hash` fast path returns with zero embeds. The corpus then
scores questions with a different geometry and retrieval silently decays.
This skill is the controlled procedure for that change: **measure first, then
decide, then migrate, then re-tune, then smoke-test.** Four stages, in that
order — never skip stage 2 (the measurement) and never re-tune before the
re-embed.
```
stage 1 probe + benchmark the new model (is it 768? is it any good?)
stage 2 measure comparability --compare (do I have to re-embed at all?)
stage 3 prod migration (backup → migrate → re-embed → verify)
stage 4 re-tune the thresholds + smoke test (battery-gated)
```
## Prerequisites
- **Phase 169 must be applied** — the tooling lives there. Check before
anything else; if either is missing, STOP and tell the user to run the phase
(`phased-execution` skill) rather than improvising:
```bash
ls scripts/reembed_embeddings.py
uv run alembic heads # must be >= 0029
```
- The db container is up: `podman compose up -d db`
- `.env` has `BOR_LLM_BASE_URL` / `BOR_LLM_API_KEY` (or `$AIPI_KEY`) pointing at
the endpoint that serves BOTH the old and the new embedding model.
- **Keep the old model served** on the endpoint for the whole bake window —
rollback means re-embedding back to it (stage "Rollback").
- Production access: read the prod host / DSN / service name from your
deployment repo or the prod env file. Never hardcode them into this file or a
commit. Below, `prod` means "run the quoted command there".
## Rules (non-negotiable)
- **Never switch the app's model before stage 2 is measured and a backup
exists.** `--apply` overwrites vectors; the `pg_dump` is the only undo.
- **Never `--stamp-only` without a `COMPATIBLE` verdict.** Stamping claims
"these vectors belong to this model", and after that claim nothing will ever
re-embed them.
- **Never re-tune the thresholds before the corpus is on ONE generation.**
Retuning against a mixed corpus tunes to noise (stage 4 is after stage 3).
- **Never run `--apply` while a sync is running** (confirm idle first).
- **The script and the app must use the same model name.** `--apply` stamps
`BOR_LLM_EMBED_MODEL` from ITS OWN environment; running it with a different
env than the app's makes every stamp a lie. Its first log line prints the
model and the sanitized base URL — read that line before walking away.
- **Check the `app_settings` overlay before changing any value** (phase 168):
a value saved in the Settings panel is a DB row and **beats `.env`**. Every
stage that changes a value starts with the `select … from app_settings`
check in step 4.1.
- **Do not edit `app/` or `alembic/` from this skill.** If the new model
exposes an app defect (e.g. a different input-token ceiling), report it as a
phase candidate — the batching constants in `app/rag/llm.py`
(`_TOKENS_PER_CHAR`, `_REQUEST_TOKEN_BUDGET`) are code, not a drive-by edit.
- Retrieval is **never** filtered by generation (phase 169 LOCKED A3): a mixed
corpus keeps ranking, it just ranks wrongly. The card, the endpoint and the
sync log are the signals — trust them over "it seems to work".
- ntfy (if used at all) is **outbound only** — milestones, never questions.
## Stage 1 — Probe and benchmark the new model
Point at the new model *for one process at a time*; do not touch the running
app yet.
```bash
NEW=embeddinggemma-2 # whatever the endpoint calls it
BOR_LLM_EMBED_MODEL=$NEW uv run python -m scripts.llm_probe
BOR_LLM_EMBED_MODEL=$NEW uv run python -m scripts.test_embed_model --model "$NEW" --runs 3
```
- `llm_probe` prints the output dimension against `BOR_EMBEDDING_DIM` (768).
**If the dimension differs, STOP** — this skill does not apply. That is the
README's "Embedding dimension mismatch" path (`BOR_EMBEDDING_DIM` + drop and
recreate the chunks table), which is destructive in a different way.
- `test_embed_model` runs the three gated checks (dimension / cosine margin ≥
0.05 on the fixed semantic-pair corpus / speed) and **appends its rows to
`benchmarks/model_benchmarks.csv` automatically**. Record the numbers; a
cosine-margin failure means the model is not fit to serve, full stop.
- Note the **speed** (the old model's reference is ~94 vec/s → the live corpus
of 1,000 docs / 8,866 chunks re-embeds in minutes; if the new model is
10× slower, plan the window accordingly).
- **Input ceiling check** — the app batches embeddings against the current
endpoint's ~1024-token hard rejection. Probe it once with a long input:
```bash
BOR_LLM_EMBED_MODEL=$NEW uv run python - <<'PY'
import asyncio, httpx
from app.config import get_settings
from app.rag.llm import LLMClient
async def go():
llm = LLMClient()
for n in (1200, 4000, 12000):
try:
await llm.embed_one("word " * n)
print(f"ok ~{n} chars")
except Exception as e:
print(f"FAIL ~{n} chars: {type(e).__name__}: {str(e)[:120]}")
asyncio.run(go())
PY
```
Fewer accepted chars than the old model → `BOR_EMBED_QUESTION_MAX_CHARS`
(env) must come down AND the `_REQUEST_TOKEN_BUDGET` constant
(`app/rag/llm.py`) is now wrong: flag it as a phase, don't hand-edit.
More accepted → nothing to do; the existing budget is merely conservative.
## Stage 2 — Measure comparability (the answer to "are they compatible?")
Run the measurement as its **own process with an env override**. It reads and
writes nothing, and it does not disturb the running app — the app keeps serving
the old model while you measure.
```bash
BOR_LLM_EMBED_MODEL=$NEW uv run python -m scripts.reembed_embeddings --compare --sample 25
```
It embeds each sampled chunk's **stored text** with the new endpoint and
compares against the **stored vector**, printing `mean / median / p10 / min`
cosine agreement and a verdict:
| verdict | mean cosine | what it means | what to do |
|---|---|---|---|
| `COMPATIBLE` | ≥ 0.995 | same geometry, different name/build | stage 3 with `--stamp-only` — **no re-embed needed** |
| `DRIFT` | 0.90 – 0.995 | related but not the same space | stage 3 with `--apply` (cheap; note it in the record) |
| `INCOMPATIBLE` | < 0.90 | different space — this is the normal case for a model upgrade | stage 3 with `--apply` |
- Exit codes: `0` COMPATIBLE, `3` non-compatible verdict (the answer is "no"),
`1` the tool/boot/endpoint failed (something broke). Branch on those, never
on the pretty text.
- The bar is **0.995, not 1.0**, because pgvector stores `float4` while the app
writes Python floats: an unchanged model reproduces a vector to float4
round-trip precision (~0.9999), never exactly 1.0. Expecting exactly 1.0
makes a healthy corpus read as `DRIFT`.
- Also print the **baseline** you are protecting, so stage 4 has a "before":
```bash
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt \
| tee /tmp/embed_baseline_$(date +%Y%m%d_%H%M%S).txt
podman exec brain-of-reese_db_1 psql -U reese -d brain_of_reese -c "
select percentile_cont(0.1) within group (order by top_score) as p10,
percentile_cont(0.5) within group (order by top_score) as p50,
percentile_cont(0.9) within group (order by top_score) as p90,
count(*) filter (where deflected) as deflected, count(*) as turns
from query_log where created_at > now() - interval '90 days';"
```
(On prod, run the equivalent `psql` against the prod database and keep both
outputs out of git — `/tmp` is fine.)
## Stage 3 — Migrate production
Do these back to back in one maintenance window. The corpus is
1,000 docs / 8,866 chunks ≈ minutes at the benchmarked speed; the mixed window
is as long as the re-embed, and the Settings card tells you when it is over.
### 3.1 Confirm nothing is mid-flight, then BACK UP
```bash
curl -s -b "$JAR" https://<prod-host>/api/sync/status # must be idle (verify the route in app/api/sync.py)
podman exec <prod-db-container> pg_dump -U <user> <db> -Fc \
> /tmp/pre_embed_migration_$(date +%Y%m%d_%H%M%S).dump
ls -lh /tmp/pre_embed_migration_*.dump
podman exec <prod-db-container> pg_restore --list < /tmp/pre_embed_migration_<timestamp>.dump | head
```
Copy the dump **off the app box**, and only continue once `pg_restore --list`
reads it. The dump — not the rollback section below — is the real undo.
### 3.2 Apply the schema
```bash
uv run alembic upgrade head # 0029_chunks_embedding_model, reversible
```
Adding `chunks.embedding_model` is instant and non-destructive; existing rows
stay `NULL` = **unknown by design** (never backfilled with a guess).
### 3.3 Flip the app, then re-embed (or stamp)
Flip the app's model, then immediately run the repair with the **same** env:
- `.env` + restart (`BOR_LLM_EMBED_MODEL=$NEW`), **or**
- the admin **Settings** panel → `llm_embed_model` → Save (phase 168 applies it
live, no restart — and writes an `app_settings` row, which then beats `.env`
for the rest of the app's life; that is fine, just know where the truth is).
```bash
# the normal case (DRIFT / INCOMPATIBLE):
uv run python -m scripts.reembed_embeddings --apply
# only on a COMPATIBLE verdict — rewrites no vectors, just the stamps:
uv run python -m scripts.reembed_embeddings --stamp-only
```
Read the first log line (`mode`, `model`, `dim`, `base=`) before walking away,
and the summary line when it finishes:
```
reembed: model=<stamp> mode=apply chunks=8866 batches=<n> remaining=0 duration_ms=<ms>
```
`remaining=0` is the goal. The run is resumable by construction (it selects
`WHERE embedding IS NULL OR embedding_model IS DISTINCT FROM <model>` and
commits per batch): if it dies mid-way, fix the cause and re-run — it picks up
where it stopped. It never writes a NULL embedding and it never bumps
`sources_meta.version`, so saved chats do not go stale over a re-embed.
### 3.4 Verify the migration before touching any value
```bash
podman exec brain-of-reese_db_1 psql -U reese -d brain_of_reese -c "
select coalesce(embedding_model,'<unknown>') as model, count(*) as chunks,
count(embedding) as with_vector
from chunks group by 1 order by 2 desc;"
uv run python -m scripts.reembed_embeddings --compare --sample 25 # must now read COMPATIBLE (~1.0)
curl -s -b "$JAR" https://<prod-host>/api/admin/embeddings # "stale": false, "unknown": 0, "null_vector": 0
```
Then in the browser: **Settings → Embedding generation** shows the clean
`status` card, not the `alert`. And the post-sync line agrees — trigger nothing,
just check the last sync's log:
```bash
grep ' :: embeddings model=' <app log> | tail -1 # mismatched=0 unknown=0
```
## Stage 4 — Re-tune the values, then smoke-test them
The three honesty/citation values were hand-tuned against the OLD model's
cosine distribution (`app/config.py`: `relevance_threshold=0.62`,
`lexical_support_floor=0.35`, `source_usefulness_floor=0.35`). A new model moves
the whole distribution, so they are almost certainly wrong after stage 3 — in
whichever direction the percentiles moved.
### 4.1 Know which layer wins
```sql
select key, value from app_settings
where key in ('llm_embed_model','relevance_threshold',
'lexical_support_floor','source_usefulness_floor');
```
A row here **beats `.env`** (phase 168's overlay). Change values in ONE place —
the panel (live, DB row) or `.env` (restart, env value) — and never let a stale
DB row silently pin an old number.
### 4.2 Measure, then set
1. Re-run the battery and collect two numbers: the **best cosine of known-good
questions** (from `/tmp/embed_baseline_*.txt`'s same questions) and the best
cosine of the battery's **out-of-KB control** question.
2. `relevance_threshold` goes **between the two clusters** (midpoint). Good
hits clustering lower than before → lower it; nonsense questions scoring
higher → raise it.
3. `lexical_support_floor` sits at the bottom of the good-hit cluster — it is
the bar a lexical hit needs vector corroboration to flip a turn to HIGH.
`source_usefulness_floor` (the citation tier) sits at the same bar by
default.
4. The validators enforce `0 ≤ floor ≤ relevance_threshold`
(`app/config.py`'s `_lexical_support_floor_bounds` /
`_source_usefulness_floor_bounds`) — a floor above the threshold is refused
at startup (or a 422 in the panel), which is the intended guard, not a bug.
5. Apply → re-run the battery → iterate. Two or three passes is normal; keep
each pass's numbers.
Symptom → first move:
| symptom after the swap | first move |
|---|---|
| real questions deflect, cosines cluster low | lower `relevance_threshold`, then both floors (keep floors ≤ threshold) |
| out-of-KB questions answered confidently | raise `relevance_threshold` |
| a single weak token match promotes to HIGH | raise `lexical_support_floor` |
| citations present but irrelevant | raise `source_usefulness_floor` |
| `embedding failed (too-large)` on questions | lower `BOR_EMBED_QUESTION_MAX_CHARS`; if import batches also fail, `_REQUEST_TOKEN_BUDGET` needs a phase |
### 4.3 Smoke test
- [ ] `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` — every gated item in that file passes (the gitea / forgejo / gateway name questions, the qwen3.8-27b incident regression, both IKEA `BESTÅ`/`besta` spellings, and the out-of-KB control's verdict). This file is the ruler — **read it, never edit it** to make a run pass.
- [ ] A real question in the browser answers with citations and a plausible `top_score` (per-turn line: `grep ' :: chat: ' <app log> | tail -5`).
- [ ] The out-of-KB control question still gets the amber honest deflection — a model that scores everything high has NOT been "tuned", it has been blinded.
- [ ] `GET /api/admin/embeddings` → `stale: false`, `unknown: 0`, `null_vector: 0`; the Settings card is the clean `status` variant.
- [ ] `psql`: the new cosine percentiles sit where the retuned threshold expects them, and `select deflected, count(*) from query_log group by 1` over the next day matches the stage-2 baseline's deflection rate within a few points.
- [ ] `uv run python -m scripts.api_payload_audit` still clean (the migration added one small admin endpoint).
### 4.4 Record it
- `benchmarks/model_benchmarks.csv` already has stage 1's rows (written by
`scripts.test_embed_model`).
- If a **default** in `app/config.py` changed as a result, that is its own
deliberate commit (`tune(rag): …`, with the before/after percentiles in the
body) — never smuggled into a migration.
- Commit any `.env.example` / README diff. Note in the body: date, old → new
model, the `--compare` verdict + mean cosine before and after, chunks
re-embedded, and the final three values.
## Rollback
Pick by what went wrong:
1. **Vectors unchanged (`--stamp-only` path):** flip `llm_embed_model` back
(panel or `.env` + restart). Done — the vectors were never touched.
2. **Vectors re-embedded, app box still healthy:** flip `llm_embed_model` back
to the old model (which must still be served) and run
`uv run python -m scripts.reembed_embeddings --apply` again — it re-embeds
the corpus back into the old geometry because the selection is "stamp differs
from the configured model". Minutes, same window as the forward migration.
3. **Something worse (bad dump, half-run, corrupted corpus):** restore the stage
3.1 dump — the exact pre-migration state, vectors and stamps included:
```bash
podman exec <prod-db-container> pg_restore -U <user> -d <db> --clean --if-exists --no-owner \
< /tmp/pre_embed_migration_<timestamp>.dump
```
Then verify the fingerprint (`documents` / `chunks` counts and
`select count(*) from chunks where embedding_model is not null`) before
sending traffic back.
## Troubleshooting
- **`EmbeddingDimensionError` / dimension ≠ 768** — the dimension changed, so
this skill is the wrong tool: README → "Embedding dimension mismatch"
(`BOR_EMBEDDING_DIM`, drop + recreate `chunks`, re-import). Do not "make it
fit" by resizing the column.
- **`reembed: FAILED … resume with:`** — the run stopped loud, on purpose. The
batches already committed are done; the rest are pending. Find the cause
(endpoint outage, oversized input, a pathological chunk) and re-run the
printed command. Never hand-edit vectors.
- **`remaining=` never reaches 0** — some rows never match. Look at them:
`select id, length(content), embedding_model from chunks where embedding_model
is distinct from '<configured>' order by id limit 20;` Empty `content` or a
content the endpoint rejects is the usual answer.
- **Card stuck on `unknown`** — those are pre-phase-169 rows (stamp `NULL`,
which is *unknown*, not "wrong"). The sync logs them at INFO and never warns,
and the card keeps the calm `status` variant. They resolve the first time
they are legitimately re-embedded, or now via `--stamp-only` (COMPATIBLE
only) / `--apply`.
- **Verdict `DRIFT` with mean 0.97-0.99** — usually a different server build /
quantization of a nominally identical model. Re-embed anyway (it is minutes);
note it in the record so nobody re-litigates it later.
- **The card and the app disagree about which model is live** — the overlay:
a `app_settings.llm_embed_model` row beats `.env`. `select key, value from
app_settings where key='llm_embed_model';`, then fix it in the panel (or
`delete from app_settings where key='llm_embed_model';` to fall back to env).
- **Everything still looks fine but answers got worse** — that is the failure
mode this whole procedure exists for, and it hides in the `top_score`
column, not in the logs. Compare stage 2's percentile snapshot with
`query_log.top_score` over the last day; a collapsed spread (e.g. p10 ≈ p90)
means a mixed generation or a threshold that no longer discriminates.
+16
View File
@@ -2,6 +2,22 @@
# Copy to `.env` and adjust: cp .env.example .env # Copy to `.env` and adjust: cp .env.example .env
# (`.env` is gitignored; never commit secrets.) # (`.env` is gitignored; never commit secrets.)
# --- DB setting overrides (admin Settings panel, phase 168) ---
# Precedence: the app reads every BOR_* from the environment / this
# file first; an override the admin saves in the Settings panel
# (stored in Postgres, `app_settings`) WINS over that value for the
# key — with no restart. The panel's per-key Reset (or "Clear all
# overrides") deletes the DB row and this value wins again. The
# panel NEVER writes this file (LOCKED A1).
# Kill switch: BOR_SETTINGS_DB=0 disables ALL DB overrides (the app
# reads exactly this file / the environment). Set it in the CONTAINER
# ENV, not here — a 0 committed in this file is a silent "my change
# did nothing" that the panel cannot see or fix.
# "No restart" is not universal: the values the panel marks "restart
# required" (DB pool sizes, session cookie wiring, log level,
# compression, the static dir) apply on the next
# `podman compose restart app`; everything else applies immediately.
# --- App --- # --- App ---
BOR_ENVIRONMENT=development BOR_ENVIRONMENT=development
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39) # BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
+40
View File
@@ -67,6 +67,7 @@ client-side router. Direct URLs deep-link to the matching view.
| **Saved chats** | `/history.html` | Admin only | | **Saved chats** | `/history.html` | Admin only |
| **Access tokens** | `/tokens.html` | Admin only | | **Access tokens** | `/tokens.html` | Admin only |
| **Theme** | `/theme.html` | Admin only | | **Theme** | `/theme.html` | Admin only |
| **Settings** | `/settings.html` | Admin only |
| **Document viewer** | `/document.html?source=X&path=Y` | Token users & admins | | **Document viewer** | `/document.html?source=X&path=Y` | Token users & admins |
| **Login** | `/login.html` | Everyone | | **Login** | `/login.html` | Everyone |
| **Shared chat** | `/shared/<token>` | Everyone (anonymous) | | **Shared chat** | `/shared/<token>` | Everyone (anonymous) |
@@ -207,6 +208,22 @@ chat. Admin-only view.
Generate tokens and hand them out — a token opens chat, the answers, and Generate tokens and hand them out — a token opens chat, the answers, and
the documents they cite. Shared chats stay open to everyone. the documents they cite. Shared chats stay open to everyone.
### Settings (`/settings.html`)
Edit any setting that today lives in `.env` — every `BOR_*` setting, grouped
and type-correct — from the browser. A value you **Save** is stored in the
database and **wins over the environment variable from that instant on, with
no restart** (boot-wired settings — the pool, the session middleware, the log
level — carry a `restart required` badge and apply on the next start); **Reset**
hands control back to the environment, and **Clear all overrides** empties the
override table. `.env` is never written: the environment stays the base, the
database row wins. Secrets are write-only (you set a value, never read one
back); a value that fails validation is rejected before anything is stored.
Admin-only — anonymous visitors see a sign-in gate.
The overlay can be switched off for the whole process with
`BOR_SETTINGS_DB=0` (env-only; the panel stays visible and says so).
### Shared chat (`/shared/<token>`) ### Shared chat (`/shared/<token>`)
A read-only snapshot of any conversation, shareable via a link. Open to A read-only snapshot of any conversation, shareable via a link. Open to
@@ -1075,6 +1092,29 @@ Everything is an environment variable (the `BOR_` prefix, plus
[`.env.example`](.env.example) is documented below** — copy that file [`.env.example`](.env.example) is documented below** — copy that file
to `.env` and adjust. to `.env` and adjust.
**DB-over-env overrides (phase 168):** the precedence for every setting
is **`app_settings` row → `BOR_*` environment → code default** — an
override the admin saves in the Settings panel is stored in Postgres
(`app_settings`, one row per key) and **wins over the env var for
that key — with no restart**. The panel never writes `.env` (the env
value stays the base; per-key Reset deletes the row and the env value
wins again). `BOR_SETTINGS_DB=0` (set in the **container env**, not
`.env` — a committed `0` is a silent "my change did nothing")
disables all DB overrides — the pre-phase behavior. Two keys are NOT
overridable: `BOR_DATABASE_URL` (it builds the very engine that would
read the override) and `BOR_EMBEDDING_DIM` (fixed at `chunks` table
creation). A stored value that fails the setting's validators is
skipped at read with a logged reason — a DB row never bricks boot.
"No restart" is not universal: the settings the panel marks
**restart required** (DB pool sizes, session cookie wiring, log
level, compression, the static dir) apply on the next
`podman compose restart app`; everything else applies immediately.
Admins read/write the overrides through the admin-only
`/api/admin/settings` routes (GET the catalogue, PUT `/{key}` set one
value, DELETE `/{key}` reset one, DELETE all clear everything);
secrets are write-only there (the response reports `has_value`,
never the plaintext).
### App & branding ### App & branding
| Env | Default | Meaning | | Env | Default | Meaning |
+81
View File
@@ -0,0 +1,81 @@
"""app_settings: admin DB-side overrides for any BOR_* setting (phase 168, task 01)
Revision ID: 0028
Revises: 0027
Create Date: 2026-10-09
``TODO.md`` L3: "I want an admin panel that can edit anything in .env
and save it to the database. The database value should override the
env var. The change should take effect on clicking 'save' on that
page." This revision carries the storage half of that (phase 168 D1)
— the resolver (D2) and the live-apply hook (D3) are task 02/03.
* ``app_settings`` — one row per admin setting override, the whole
table a narrow key/value pair:
* ``key`` — VARCHAR(100) NOT NULL, PRIMARY KEY. The BARE
``Settings`` field name (``relevance_threshold``) — never the
``BOR_…`` form: the prefix is a presentation concern
(``"BOR_" + key.upper()``), so a stored prefix could double up.
The PK is the uniqueness guard: two rows for one setting would
make "which one wins" undefined, and Postgres enforces it for
free (the ``ui_settings`` single-row shape is not an option here
— there are 75 settings, and a wide row would need per-field DDL
for every new one, defeating "edit ANYTHING in .env").
* ``value`` — TEXT NOT NULL. The value in the SAME textual form
the env var would carry (``"0.55"``, ``"true"``, ``"md,txt"``,
a JSON array for the one ``list[str]`` field). Storing the env-
native string — not a typed column — keeps coercion and
validation in exactly one place (the pydantic validators the
overlay runs the full candidate set through, phase 168 D1/A5),
and makes "anything in .env" work for all 75 settings with zero
per-field DDL. NOT NULL on purpose: "clear the override" is a
DELETE of the row (the panel's per-row Reset / Clear all), never
an empty value, so the resolver never has to distinguish
"no override" from "override to empty".
* ``updated_at`` — TIMESTAMPTZ NOT NULL, ``now()`` server default
(the ``folder_summaries`` / ``kb_overview.updated_at`` house
style): when the admin last saved the override.
No index beyond the PK: the whole table is read by primary key order
in ONE ``SELECT`` per settings resolution (the ``ui_settings``
"display fact, never a query surface" note, 0027).
One additive, fully reversible migration (A13).
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
def upgrade() -> None:
# No index beyond the PK (a display fact, never a query surface —
# the whole table is one SELECT per resolution) and no server
# default on ``value`` (NOT NULL — the reset is a DELETE).
op.create_table(
"app_settings",
sa.Column("key", sa.String(length=100), primary_key=True),
sa.Column("value", sa.Text(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
# The table is the only 0028 artefact — dropping it leaves 0027's
# schema byte-identical (A13, fully reversible). No data is lost
# by the drop in any meaningful sense: the values are re-enterable
# from the panel (they are an override of the env, not a source of
# truth — the env file stays the base, LOCKED A1).
op.drop_table("app_settings")
+347
View File
@@ -0,0 +1,347 @@
"""The admin Settings panel's read/write surface (phase 168, task 04).
``TODO.md`` L3: "…can edit anything in .env and save it to the
database. The database value should override the env var. The change
should take effect on clicking 'save'."
The whole router sits behind :func:`app.core.auth.require_admin`
(router-wide ``dependencies`` — the :mod:`app.api.ui_settings` pattern):
anonymous callers AND token users get 403 on every route (only the
admin retunes the deployment).
Routes (under ``/api`` via the ``main`` registration):
* ``GET /api/admin/settings`` — the whole catalogue: one item per
``Settings`` field, in ``GROUPS`` order (the stable order the E2E
pins against), each with its effective value in env-native string
form, a ``db``/``env``/``default`` source badge, and the three
capability flags. Reads only; creates nothing.
* ``PUT /api/admin/settings/{key}`` — set one value (the body's string
is stored as-is in ``app_settings``). Fail loud BEFORE the write
(LOCKED A5): the FULL candidate set (every overridable stored row +
this key/value, layered over the env base) is run through the
real ``Settings`` validators — a bad shape OR a cross-field rule
(e.g. ``lexical_support_floor`` above a DB-overridden
``relevance_threshold``) is a 422 that names the field and stores
NOTHING. Unknown key → 404; a non-overridable key (LOCKED A3) → 422
naming the env var; a blank value for a secret → 422 (an empty API
key / session secret is a self-lockout; ``DELETE`` is the explicit
clear). A successful write commits, calls ``invalidate_settings()``
(task 03 makes it live — no restart) and returns the refreshed item.
* ``DELETE /api/admin/settings/{key}`` — reset to env: deletes the row
(idempotent — deleting an absent key is a success, not a 404),
invalidates, returns the item with ``source="env"``/``"default"``.
* ``DELETE /api/admin/settings`` — "Clear all overrides": deletes every
row, invalidates, ``{"cleared": rowcount}``.
Three invariants (the unit tests pin them):
1. **Validate-before-store** — the write path never persists a value
the ``Settings`` validators reject (A5);
2. **Masked secrets** (LOCKED A4) — a ``secret`` field reports
``masked``/``has_value`` only, never the plaintext, in no response
body; every write logs one line carrying the key and the action,
NEVER the value (AGENTS rule 10);
3. **Never write ``.env``** (LOCKED A1) — this module contains no file
writes, never shells out and never ``eval``s a value; the env file
stays the base, the DB row wins, the reset deletes the row.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Literal, get_origin
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from sqlalchemy import delete, func, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.config import Settings, get_settings, invalidate_settings
from app.core.auth import require_admin
from app.core.settings_catalog import CATALOG, GROUPS, env_var
from app.core.settings_overlay import LIST_FIELDS, _coerce, enabled
from app.db import get_db
from app.models import AppSetting
from app.schemas import (
AdminSettingIn,
AdminSettingItem,
AdminSettingsCleared,
AdminSettingsOut,
AdminSettingsTotals,
)
logger = logging.getLogger("app.api.admin_settings")
router = APIRouter(
prefix="/admin/settings",
tags=["admin-settings"],
dependencies=[Depends(require_admin)], # phase 168: the settings panel is admin-only
)
def _field_type(key: str) -> Literal["int", "float", "bool", "str", "json"]:
"""The panel control kind for one field, from its annotation
(``bool`` before ``int`` — ``isinstance(True, int)`` is ``True``;
the one ``list[str]`` field is ``json``, K2)."""
annotation = Settings.model_fields[key].annotation
if get_origin(annotation) in (list, tuple):
return "json"
if annotation is bool:
return "bool"
if annotation is int:
return "int"
if annotation is float:
return "float"
return "str"
def _display(field_name: str, value: Any) -> str:
"""Stringify one effective value in the env-native string form
(D1: stored strings, one coercion place — the router's display is
the mirror of ``settings_overlay._coerce``): bool →
``"true"``/``"false"``, the one ``list[str]`` field → a JSON array
(what the env spelling would carry, K2), everything else
``str()``."""
if isinstance(value, bool):
return "true" if value else "false"
if field_name in LIST_FIELDS:
return json.dumps(value)
return str(value)
def _rows(db: Session) -> dict[str, str]:
"""``SELECT key, value FROM app_settings`` — the whole table in one
round trip (the ``ui_settings`` "display fact, never a query
surface" note)."""
return {key: value for key, value in db.execute(text("SELECT key, value FROM app_settings"))}
def _item(
key: str,
rows: dict[str, str],
effective: Settings,
) -> AdminSettingItem:
"""One catalogue entry for one field (the stable builder for all
three read paths — GET, the PUT/DELETE returns, and the error
paths' neighbours)."""
meta = CATALOG[key]
value = getattr(effective, key)
#: Only an OVERRIDABLE row counts as an override: a hand-inserted
#: ``database_url`` / ``embedding_dim`` row is never in effect
#: (the overlay drops it, LOCKED A3) — it is not an override.
has_override = meta.overridable and key in rows
#: ``source="db"`` means "in effect": a row the overlay actually
#: applies. With the kill switch off, no row is in effect, so the
#: badge falls back to env/default (the panel's ``overlay_enabled``
#: banner explains the rest — D6).
in_effect = has_override and enabled()
if in_effect:
source: str = "db"
elif os.environ.get(env_var(key)) is not None:
source = "env"
else:
source = "default"
if meta.secret:
# LOCKED A4 — write-only: the plaintext appears in NO response;
# the read side reports has_value (is a non-empty value in
# effect at all) and nothing else about it.
return AdminSettingItem(
key=key,
env_var=env_var(key),
group=meta.group,
label=meta.label,
type=_field_type(key),
secret=True,
restart_required=meta.restart_required,
overridable=meta.overridable,
source=source,
value=None,
masked=True,
has_value=bool(value),
has_override=has_override,
db_value=None,
default="",
)
return AdminSettingItem(
key=key,
env_var=env_var(key),
group=meta.group,
label=meta.label,
type=_field_type(key),
secret=False,
restart_required=meta.restart_required,
overridable=meta.overridable,
source=source,
value=_display(key, value),
masked=False,
has_value=bool(_display(key, value)),
has_override=has_override,
db_value=rows.get(key) if has_override else None,
default=_display(key, Settings.model_fields[key].default),
)
def _validate_candidate(candidate: dict[str, str]) -> None:
"""Validate the FULL candidate set before anything is stored
(LOCKED A5, D5): every overridable stored row + the new
key/value, layered over the env base, through the SAME coercion
(``settings_overlay._coerce`` — one place for the string →
field-value decision) and the REAL ``Settings`` validators. A bad
shape OR a cross-field rule is a 422 that names the field — and
nothing is written (the caller writes only on return)."""
kwargs: dict[str, Any] = {}
for key, raw in candidate.items():
try:
kwargs[key] = _coerce(key, raw)
except ValueError as exc:
raise HTTPException(
status_code=422, detail=f"invalid value for {key}: {exc}"
) from exc
try:
Settings(**kwargs)
except ValidationError as exc:
err = exc.errors()[0]
field = str(err["loc"][0])
raise HTTPException(
status_code=422, detail=f"invalid value for {field}: {err['msg']}"
) from exc
@router.get("", response_model=AdminSettingsOut)
def get_catalogue(
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingsOut:
"""The whole catalogue (75 items, ``GROUPS`` order — the stable
order the E2E pins against).
Reads the table once, resolves the effective ``Settings`` through
``get_settings()`` (the overlay — DB-over-env, task 02), and
emits one item per field. GETs log nothing here: the phase-167
``ResponseBudgetMiddleware`` already writes the
``api method=… bytes=…`` line, and the whole catalogue must stay
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (75 items ≈ 20 KB
— verified in task 06 with the audit script).
"""
rows = _rows(db)
effective = get_settings()
items = [
_item(key, rows, effective)
for group in GROUPS
for key, meta in CATALOG.items()
if meta.group == group
]
return AdminSettingsOut(
items=items,
totals=AdminSettingsTotals(
count=len(items),
overridden=sum(1 for item in items if item.source == "db"),
),
overlay_enabled=enabled(),
)
@router.put("/{key}", response_model=AdminSettingItem)
def set_value(
key: str,
payload: AdminSettingIn,
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingItem:
"""Set one value (store it in ``app_settings`` — env-native string
form, D1), make it live (``invalidate_settings()`` — task 03) and
return the refreshed item.
The write path (LOCKED A5, A3, A4): unknown key → 404; a
non-overridable key → 422 naming the env var (A3: the engine is
built from ``database_url`` — an override is a chicken-and-egg
brick; ``chunks.embedding`` is fixed at ``embedding_dim`` by A6,
so a dim override would corrupt retrieval); a blank value for a
secret → 422 (a self-lockout; ``DELETE`` is the explicit clear);
then the FULL candidate set is validated before anything is
stored (a 422 names the field and writes nothing).
"""
meta = CATALOG.get(key)
if meta is None:
raise HTTPException(status_code=404, detail=f"Unknown setting {key}")
if not meta.overridable:
raise HTTPException(
status_code=422,
detail=f"{env_var(key)} is not settable from the database",
)
if meta.secret and not payload.value.strip():
raise HTTPException(
status_code=422,
detail=(
f"{env_var(key)} must not be blank — use Reset to fall "
"back to the environment"
),
)
# The candidate set: the overridable stored rows + this key/value
# (the same drop rules ``settings_overlay.merge`` applies on the
# read side — an orphan / non-overridable row can never affect
# validation or the effective set).
rows = _rows(db)
candidate = {
k: v for k, v in rows.items() if k in CATALOG and CATALOG[k].overridable
}
candidate[key] = payload.value
_validate_candidate(candidate)
# The write: upsert on the PK (INSERT … ON CONFLICT (key) DO
# UPDATE — the row's updated_at moves with the save).
stmt = pg_insert(AppSetting).values(key=key, value=payload.value)
stmt = stmt.on_conflict_do_update(
index_elements=["key"],
set_={"value": stmt.excluded.value, "updated_at": func.now()},
)
db.execute(stmt)
db.commit()
# One line per write, never the value (AGENTS rule 10, A4).
logger.info("settings action=set key=%s status=200", key)
# Make it live (task 03): clear the cache + run the runtime reset
# hooks, then report the refreshed item from the fresh settings.
invalidate_settings()
return _item(key, _rows(db), get_settings())
@router.delete("/{key}", response_model=AdminSettingItem)
def reset_value(
key: str,
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingItem:
"""Reset one key to env (LOCKED A1: the reset is a row DELETE —
never an empty value, so the resolver never distinguishes "no
override" from "override to empty").
Idempotent: deleting a key with NO ROW is a success, not a 404
(the panel's per-row Reset is safe to double-click — the env
value is already in effect; an unknown *setting* key is a 404,
like the PUT — a typo in the key is a client bug, and the item
shape needs the catalog metadata, so "nothing to reset" can only
mean "the row is already gone"). A successful delete logs one
value-free line; a no-op double Reset logs nothing.
"""
if key not in CATALOG:
raise HTTPException(status_code=404, detail=f"Unknown setting {key}")
result = db.execute(
delete(AppSetting).where(AppSetting.key == key).returning(AppSetting.key)
)
db.commit()
if len(result.all()):
logger.info("settings action=clear key=%s", key)
invalidate_settings()
return _item(key, _rows(db), get_settings())
@router.delete("", response_model=AdminSettingsCleared)
def reset_all(db: Session = Depends(get_db)) -> AdminSettingsCleared: # noqa: B008
"""The panel's "Clear all overrides": delete every row (the env
values win again for all 75 settings), invalidate (task 03) and
report how many rows went."""
result = db.execute(delete(AppSetting).returning(AppSetting.key))
db.commit()
count = len(result.all())
logger.info("settings action=clear_all count=%d", count)
invalidate_settings()
return AdminSettingsCleared(cleared=count)
+16
View File
@@ -326,6 +326,22 @@ def get_llm() -> LLMClient:
return _llm return _llm
def reset_runtime_caches() -> None:
"""Drop the settings-derived singletons (phase 168, task 03).
Called by ``invalidate_settings()`` after the admin saves. The next
turn rebuilds the client from the CURRENT ``get_settings()`` — an
in-flight turn keeps the client it started with (its ``AsyncOpenAI``
transport is mid-request; swapping it would kill the stream), which
is the same accepted-semantics note as A12's in-process state.
``_chat_active`` is deliberately untouched: it counts live turns, and
the new ``chat_max_concurrent`` applies as permits are taken.
"""
global _llm, _chat_semaphore
_llm = None
_chat_semaphore = None
def _whole_line_number(value: Any) -> bool: def _whole_line_number(value: Any) -> bool:
"""A ``read`` range argument's whole-line-number guard (phase 131, """A ``read`` range argument's whole-line-number guard (phase 131,
task 04). task 04).
+34 -1
View File
@@ -905,4 +905,37 @@ class Settings(BaseSettings):
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() """Effective settings: env/.env with the admin's DB overrides layered
on top (phase 168, TODO L3: "The database value should override
the env var").
The DB half lives in :mod:`app.core.settings_overlay` — imported
LAZILY, on purpose: ``app.core.*`` imports ``app.config`` at module
scope (and ``app.db`` → ``app.config`` runs at import time), so a
module-scope import here would deadlock the import graph. The
``@lru_cache`` is unchanged from pre-phase — every existing
``get_settings.cache_clear()`` call site keeps working (they only
retune the environment, so skipping the reset hooks there is
correct), and :func:`invalidate_settings` is the save-side wrapper
that also runs the runtime reset hooks (phase 168, task 03).
"""
from app.core.settings_overlay import resolve_settings
return resolve_settings()
def invalidate_settings() -> None:
"""Save-side settings refresh (phase 168, task 02/03).
Clears the ``get_settings`` cache (the next resolution re-reads the
DB overrides — no restart) and runs the registered runtime reset
hooks (task 03: the in-process singletons — the chat LLM client
and semaphore — rebuild from the fresh settings). The six existing
``get_settings.cache_clear()`` test call sites stay the env-only
retune path: they re-resolve without running the hooks, which is
exactly what a test that only re-tunes the environment wants.
"""
get_settings.cache_clear()
from app.core.settings_overlay import run_invalidators
run_invalidators()
+1
View File
@@ -177,6 +177,7 @@ HTML_PAGES: tuple[str, ...] = (
"/history.html", # phase 50: the admin saved-chats page "/history.html", # phase 50: the admin saved-chats page
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route) "/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route) "/theme.html", # phase 91 task 04: the admin theme page (shell route)
"/settings.html", # phase 168: the admin settings view (shell route)
# phase 51: the shared page's STATIC path (the static mount serves # phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the # shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v= # dynamic /shared/<token> — both must carry the no-cache + ?v=
+350
View File
@@ -0,0 +1,350 @@
"""The admin Settings panel's catalogue (phase 168, task 02).
``TODO.md`` L3: "I want an admin panel that can edit anything in .env
and save it to the database."
This module is the single source of truth for what the panel shows and
what the DB-over-env overlay (``app.core.settings_overlay``) is allowed
to do with each of the ``Settings`` fields:
* :data:`CATALOG` maps every ``Settings`` field name to its
presentation metadata (``group`` + ``label``) and its capability
flags:
- ``secret`` — write-only (LOCKED A4): the API reports ``is_set``,
never the plaintext; a saved value is masked on read.
- ``restart_required`` — boot-wired (LOCKED A4): the middleware /
engine / logging / static mount read it once at boot, so the
override applies on the NEXT boot (the panel shows the badge).
- ``overridable`` — ``False`` for LOCKED A3's ``database_url``
(it builds the very engine that would read the override — a
chicken-and-egg brick) and ``embedding_dim`` (A6 fixes
``chunks.embedding`` at table-creation; a dim override would
corrupt retrieval).
The panel (task 05) is GENERATED from this catalogue — never
hand-written — so a new ``Settings`` field appears there only once
it has a :class:`SettingMeta`; the completeness pin
(``tests/unit/test_settings_catalog.py``: ``set(CATALOG) ==
set(Settings.model_fields)``) fails the build the moment a field is
added without metadata, so it can never silently disappear from the
panel.
* :data:`GROUPS` is the display order of the groups — it mirrors
``app/config.py``'s own section comments (App, HTTP response
compression / byte budget, UI customization, Database, LLM, Chat
history, RAG tuning, …).
* :data:`SECRETS`, :data:`RESTART_REQUIRED` and
:data:`NON_OVERRIDABLE` are DERIVED from :data:`CATALOG` — never
re-listed by hand, so they cannot drift.
:func:`env_var` keeps the ``BOR_`` prefix a pure presentation concern
(D1): the stored key is the bare ``Settings`` field name
(``relevance_threshold``), ``"BOR_" + key.upper()`` is what the panel
and the API show, and a stored prefix could double up — so it is
derived, never stored.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SettingMeta:
"""One settings field's panel metadata + capability flags."""
key: str
group: str
label: str
#: Write-only (LOCKED A4) — masked in responses, ``is_set`` only.
secret: bool = False
#: Boot-wired (LOCKED A4) — applies on the next boot.
restart_required: bool = False
#: ``False`` = read-only row (LOCKED A3).
overridable: bool = True
#: The ``BOR_`` prefix is presentation only (D1) — see :func:`env_var`.
ENV_PREFIX = "BOR_"
#: Display order of the groups (mirrors ``app/config.py``'s section
#: comments); :data:`CATALOG` is keyed in the same order.
GROUPS: tuple[str, ...] = (
"App & branding",
"HTTP & logging",
"Database",
"LLM (aipi)",
"Chat history",
"Retrieval & honesty",
"Chunking & embedding",
"Prompt budgets",
"Agent tools",
"Import & sources",
"Images",
"PDF",
"Docs push",
"Admin & security",
)
#: Every ``Settings`` field, in :data:`GROUPS` display order. Completeness
#: is pinned by ``tests/unit/test_settings_catalog.py`` (the diff is in
#: the failure message — a new setting without metadata fails the build).
CATALOG: dict[str, SettingMeta] = {
# --- App & branding ---
"app_name": SettingMeta("app_name", "App & branding", "App name"),
"app_version": SettingMeta("app_version", "App & branding", "App version"),
"environment": SettingMeta("environment", "App & branding", "Environment"),
"input_placeholder": SettingMeta(
"input_placeholder", "App & branding", "Input placeholder"
),
"footer_text": SettingMeta("footer_text", "App & branding", "Footer text"),
"suggestions": SettingMeta(
"suggestions", "App & branding", "Suggestions (JSON array)"
),
# --- HTTP & logging ---
"log_level": SettingMeta(
"log_level", "HTTP & logging", "Log level", restart_required=True
),
"log_large_response_bytes": SettingMeta(
"log_large_response_bytes",
"HTTP & logging",
"Log large response bytes",
),
"compress_min_bytes": SettingMeta(
"compress_min_bytes", "HTTP & logging", "Compress min bytes",
restart_required=True,
),
"static_dir": SettingMeta(
"static_dir", "HTTP & logging", "Static dir", restart_required=True
),
# --- Database ---
"database_url": SettingMeta(
"database_url", "Database", "Database URL",
secret=True, overridable=False,
),
"db_pool_size": SettingMeta(
"db_pool_size", "Database", "DB pool size", restart_required=True
),
"db_pool_max_overflow": SettingMeta(
"db_pool_max_overflow", "Database", "DB pool max overflow",
restart_required=True,
),
"db_pool_recycle": SettingMeta(
"db_pool_recycle", "Database", "DB pool recycle", restart_required=True
),
"chat_max_concurrent": SettingMeta(
"chat_max_concurrent", "Database", "Chat max concurrent"
),
# --- LLM (aipi) ---
"llm_base_url": SettingMeta(
"llm_base_url", "LLM (aipi)", "LLM base URL"
),
"llm_api_key": SettingMeta(
"llm_api_key", "LLM (aipi)", "LLM API key", secret=True
),
"llm_chat_model": SettingMeta(
"llm_chat_model", "LLM (aipi)", "LLM chat model"
),
"llm_embed_model": SettingMeta(
"llm_embed_model", "LLM (aipi)", "LLM embed model"
),
"llm_summary_model": SettingMeta(
"llm_summary_model", "LLM (aipi)", "LLM summary model"
),
"stream_thinking": SettingMeta(
"stream_thinking", "LLM (aipi)", "Stream thinking"
),
"llm_retries": SettingMeta("llm_retries", "LLM (aipi)", "LLM retries"),
"llm_retry_delay": SettingMeta(
"llm_retry_delay", "LLM (aipi)", "LLM retry delay"
),
"llm_timeout": SettingMeta("llm_timeout", "LLM (aipi)", "LLM timeout (s)"),
# --- Chat history ---
"history_max_turns": SettingMeta(
"history_max_turns", "Chat history", "History max turns"
),
"history_max_chars": SettingMeta(
"history_max_chars", "Chat history", "History max chars"
),
# --- Retrieval & honesty ---
"embedding_dim": SettingMeta(
"embedding_dim", "Retrieval & honesty", "Embedding dim",
overridable=False,
),
"top_n_docs": SettingMeta("top_n_docs", "Retrieval & honesty", "Top N docs"),
"relevance_threshold": SettingMeta(
"relevance_threshold", "Retrieval & honesty", "Relevance threshold"
),
"lexical_support_floor": SettingMeta(
"lexical_support_floor", "Retrieval & honesty", "Lexical support floor"
),
"source_usefulness_floor": SettingMeta(
"source_usefulness_floor",
"Retrieval & honesty",
"Source usefulness floor",
),
"related_max_docs": SettingMeta(
"related_max_docs", "Retrieval & honesty", "Related max docs"
),
"suggested_docs": SettingMeta(
"suggested_docs", "Retrieval & honesty", "Suggested docs"
),
"suggestion_preview_chars": SettingMeta(
"suggestion_preview_chars",
"Retrieval & honesty",
"Suggestion preview chars",
),
"hybrid_vector_candidates": SettingMeta(
"hybrid_vector_candidates",
"Retrieval & honesty",
"Hybrid vector candidates",
),
"hybrid_lexical_candidates": SettingMeta(
"hybrid_lexical_candidates",
"Retrieval & honesty",
"Hybrid lexical candidates",
),
"rrf_k": SettingMeta("rrf_k", "Retrieval & honesty", "RRF k"),
"recency_boost": SettingMeta(
"recency_boost", "Retrieval & honesty", "Recency boost"
),
"recency_half_life_days": SettingMeta(
"recency_half_life_days",
"Retrieval & honesty",
"Recency half-life days",
),
"name_hit_bonus": SettingMeta(
"name_hit_bonus", "Retrieval & honesty", "Name hit bonus"
),
# --- Chunking & embedding ---
"chunk_target_chars": SettingMeta(
"chunk_target_chars", "Chunking & embedding", "Chunk target chars"
),
"chunk_overlap_chars": SettingMeta(
"chunk_overlap_chars", "Chunking & embedding", "Chunk overlap chars"
),
"embed_batch_size": SettingMeta(
"embed_batch_size", "Chunking & embedding", "Embed batch size"
),
"embed_question_max_chars": SettingMeta(
"embed_question_max_chars",
"Chunking & embedding",
"Embed question max chars",
),
# --- Prompt budgets ---
"max_output_tokens": SettingMeta(
"max_output_tokens", "Prompt budgets", "Max output tokens"
),
"steering_max_chars": SettingMeta(
"steering_max_chars", "Prompt budgets", "Steering max chars"
),
"summary_max_chars": SettingMeta(
"summary_max_chars", "Prompt budgets", "Summary max chars"
),
"kb_overview_max_chars": SettingMeta(
"kb_overview_max_chars", "Prompt budgets", "KB overview max chars"
),
"overview_input_max_chars": SettingMeta(
"overview_input_max_chars",
"Prompt budgets",
"Overview input max chars",
),
"folder_summary_input_max_chars": SettingMeta(
"folder_summary_input_max_chars",
"Prompt budgets",
"Folder summary input max chars",
),
# --- Agent tools ---
"agent_max_rounds": SettingMeta(
"agent_max_rounds", "Agent tools", "Agent max rounds"
),
"read_max_chars": SettingMeta(
"read_max_chars", "Agent tools", "Read max chars"
),
# --- Import & sources ---
"import_extensions": SettingMeta(
"import_extensions", "Import & sources", "Import extensions (CSV)"
),
"git_sources": SettingMeta(
"git_sources", "Import & sources", "Git sources (CSV)"
),
"sources_dir": SettingMeta(
"sources_dir", "Import & sources", "Sources dir"
),
"upload_dir": SettingMeta("upload_dir", "Import & sources", "Upload dir"),
"upload_max_mb": SettingMeta(
"upload_max_mb", "Import & sources", "Upload max MB"
),
# --- Images ---
"images": SettingMeta("images", "Images", "Images"),
"image_extensions": SettingMeta(
"image_extensions", "Images", "Image extensions (CSV)"
),
"image_dir": SettingMeta("image_dir", "Images", "Image dir"),
"chat_image_dir": SettingMeta(
"chat_image_dir", "Images", "Chat image dir"
),
"chat_image_max_mb": SettingMeta(
"chat_image_max_mb", "Images", "Chat image max MB"
),
"answer_images": SettingMeta("answer_images", "Images", "Answer images"),
# --- PDF ---
"pdf_pictorial": SettingMeta("pdf_pictorial", "PDF", "PDF pictorial"),
"pdf_pictorial_min_chars": SettingMeta(
"pdf_pictorial_min_chars", "PDF", "PDF pictorial min chars"
),
"pdf_image_skip_blank": SettingMeta(
"pdf_image_skip_blank", "PDF", "PDF image skip blank"
),
"pdf_page_dir": SettingMeta("pdf_page_dir", "PDF", "PDF page dir"),
# --- Docs push ---
"docs_repo": SettingMeta("docs_repo", "Docs push", "Docs repo"),
"docs_branch": SettingMeta("docs_branch", "Docs push", "Docs branch"),
"docs_base_branch": SettingMeta(
"docs_base_branch", "Docs push", "Docs base branch"
),
"docs_work_dir": SettingMeta("docs_work_dir", "Docs push", "Docs work dir"),
# --- Admin & security ---
"admin_password": SettingMeta(
"admin_password", "Admin & security", "Admin password", secret=True
),
"session_secret": SettingMeta(
"session_secret", "Admin & security", "Session secret",
secret=True, restart_required=True,
),
"session_max_age": SettingMeta(
"session_max_age", "Admin & security", "Session max age",
restart_required=True,
),
"session_cookie": SettingMeta(
"session_cookie", "Admin & security", "Session cookie",
restart_required=True,
),
}
#: The masked set (LOCKED A4: write-only; ``database_url`` is read-only
#: but its URL carries credentials, so it is masked too) — derived,
#: never re-listed by hand.
SECRETS: frozenset[str] = frozenset(
key for key, meta in CATALOG.items() if meta.secret
)
#: The boot-wired set (LOCKED A4: logging, the gzip middleware, the
#: static mount, ``create_engine``, ``SessionMiddleware``) — derived.
RESTART_REQUIRED: frozenset[str] = frozenset(
key for key, meta in CATALOG.items() if meta.restart_required
)
#: The read-only set (LOCKED A3) — derived.
NON_OVERRIDABLE: frozenset[str] = frozenset(
key for key, meta in CATALOG.items() if not meta.overridable
)
def env_var(key: str) -> str:
"""The env var for one bare setting key — presentation only (D1).
The stored key is the bare ``Settings`` field name; the ``BOR_``
form is what the panel and the API show, never what is stored
(a stored prefix could double up).
"""
return ENV_PREFIX + key.upper()
+281
View File
@@ -0,0 +1,281 @@
"""The DB-over-env settings overlay (phase 168, task 02).
``TODO.md`` L3: "…save it to the database. The database value should
override the env var."
:func:`resolve_settings` layers the ``app_settings`` rows (phase 168
task 01: bare ``Settings`` field name → env-native string) over the
environment and hands every existing consumer one valid ``Settings`` —
through :func:`app.config.get_settings` (which delegates here, lazily,
under its unchanged ``lru_cache``). No consumer changes, no restart
(D2). The kill switch is env-only: ``BOR_SETTINGS_DB=0`` (read
straight from ``os.environ`` per call — it is not a ``Settings`` field,
so the escape hatch cannot disable itself).
Fail loud on write, fail soft on read (LOCKED A5, D5): a write
validates the FULL candidate set (task 04); a read that finds an
unusable row (hand-edited, or a validator changed in a later release)
skips THAT key, logs ``settings override ignored key=… reason=…`` and
applies the rest — a DB value must never brick boot, because a
bricked app cannot be un-bricked from the panel.
The overlay's engine is a lazily-created ``NullPool`` engine built from
``Settings().database_url`` (never the app's real pool): importing
``app.db`` from here would be a cycle (``app.db`` → ``app.config`` at
import time), and the pool whose size this table can change must not
be the one that reads the table. ``NullPool`` = one connection per
resolution, none held.
A missing ``app_settings`` table (fresh deployment, migrations not
applied) means "no overrides" + one DEBUG line per process (K3 — a
pre-migration deployment is a normal state); any other DB failure is
one WARNING per process naming the exception class ("settings
overrides unavailable — env values in effect"). Both "once per process"
guards are module-level flags, reset by :func:`reset_state_for_tests`.
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Callable, Mapping
from typing import Any, get_origin
from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
from sqlalchemy.pool import NullPool
from app.config import Settings
from app.core.settings_catalog import CATALOG
logger = logging.getLogger("app.settings")
#: ``BOR_SETTINGS_DB`` — env-only, read per call from ``os.environ`` so
#: tests can flip it; unset/``1`` = on, the house bool off-spellings
#: are off. It is NOT a ``Settings`` field, so the escape hatch cannot
#: disable itself from the database (D6).
_DISABLED_SPELLOS = frozenset({"0", "false", "no", "off"})
_engine: Engine | None = None
_warned_db_failure = False
_debugged_missing_table = False
_invalidators: list[Callable[[], None]] = []
#: The one ``list[str]`` field (derived — a new list field would join
#: automatically); every other field is a scalar or CSV string (K2).
LIST_FIELDS: frozenset[str] = frozenset(
name
for name, field in Settings.model_fields.items()
if get_origin(field.annotation) in (list, tuple)
)
def enabled() -> bool:
"""The ``BOR_SETTINGS_DB`` kill switch — read per call (D6)."""
value = os.environ.get("BOR_SETTINGS_DB", "1")
return value.strip().lower() not in _DISABLED_SPELLOS
def _get_engine() -> Engine:
"""The overlay's lazily-created ``NullPool`` engine (see module
docstring — never the app's real pool: import cycle + the pool
whose size this table can change must not read the table)."""
global _engine
if _engine is None:
_engine = create_engine(
Settings().database_url, poolclass=NullPool, pool_pre_ping=True
)
return _engine
def load_overrides() -> dict[str, str]:
"""``SELECT key, value FROM app_settings ORDER BY key`` — fail-soft.
A missing table (pre-migration deployment) is one DEBUG line per
process (K3); any other DB failure is one WARNING per process
naming the exception class. Both return ``{}`` — the env values
stay in effect, and the NEXT resolution retries (a boot-time miss
is never a permanently cached env-only view — the ``create_app``
prime, task 02 step 4).
"""
global _warned_db_failure, _debugged_missing_table
try:
with _get_engine().connect() as conn:
rows = conn.execute(text("SELECT key, value FROM app_settings ORDER BY key"))
return {key: value for key, value in rows}
except ProgrammingError:
# Undefined table (psycopg ``UndefinedTable`` → SQLAlchemy
# ProgrammingError): the pre-migration deployment — DEBUG (K3).
if not _debugged_missing_table:
_debugged_missing_table = True
logger.debug(
"app_settings table missing — no DB overrides in effect "
"(pre-migration deployment, phase 168 K3)"
)
return {}
except (SQLAlchemyError, OSError) as exc:
if not _warned_db_failure:
_warned_db_failure = True
logger.warning(
"settings overrides unavailable — env values in effect (%s)",
type(exc).__name__,
)
return {}
def _coerce(field_name: str, raw: str) -> Any:
"""Coerce one env-native string row value for the candidate set.
The one ``list[str]`` field (``suggestions``) is ``json.loads``-ed
— it recovers exactly what the env spelling (a JSON array, K2)
wrote. Every other field is the raw string: pydantic-settings
lax-coerces ``str`` → ``int`` / ``float`` / ``bool``, and init
kwargs rank ABOVE env in pydantic-settings — precisely "the
database value overrides the env var" (D2). A malformed
``suggestions`` JSON raises :class:`ValueError`, which
:func:`merge` turns into a per-key skip (LOCKED A5: one bad row
costs one setting, never the whole table). The return type is
``Any`` on purpose — the value is whatever pydantic will
lax-coerce, decided in exactly one place (the ``Settings``
validators).
"""
if field_name in LIST_FIELDS:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"not a JSON array ({exc.msg})") from exc
return raw
def merge(
rows: Mapping[str, str],
) -> tuple[Settings, list[tuple[str, str]]]:
"""Layer ``rows`` over the env base (``Settings()``) and validate.
Returns ``(settings, skipped)`` — ``skipped`` is the
``(key, reason)`` pairs the read-side fail-soft (LOCKED A5)
dropped so one unusable row costs one setting, not the whole
table. Dropped keys: an orphan row (not a ``Settings`` field —
the setting was removed in a later release), a LOCKED A3 key
(a hand-inserted ``database_url`` / ``embedding_dim`` row must
never hijack the engine / the fixed 768-dim table), a malformed
``suggestions`` JSON, and any value the real ``Settings``
validators reject (retried per field — ``error["loc"][0]``). Each
dropped key is logged once at ERROR
(``settings override ignored key=… reason=…``).
"""
kwargs: dict[str, Any] = {}
skipped: list[tuple[str, str]] = []
for key, raw in rows.items():
if key not in Settings.model_fields:
skipped.append((key, "unknown setting (orphan row from a removed field)"))
continue
if not CATALOG[key].overridable:
skipped.append((key, "not DB-overridable (LOCKED A3 — read-only)"))
continue
try:
kwargs[key] = _coerce(key, raw)
except ValueError as exc:
skipped.append((key, str(exc)))
while True:
try:
settings = Settings(**kwargs)
break
except ValidationError as exc:
err = exc.errors()[0]
bad = str(err["loc"][0])
if bad in kwargs:
# The offending row is an override — drop it and retry
# (LOCKED A5: one bad row costs one setting).
kwargs.pop(bad)
skipped.append((bad, err["msg"]))
continue
# The validator blames a field with NO override — its value
# comes from env/.env and collides with a cross-field
# override (e.g. the env lexical_support_floor above a
# DB-overridden relevance_threshold). The env base alone is
# always valid (the app booted with it pre-phase), so try
# dropping the override(s) until the candidate set is valid
# again — one unresolvable collision costs the colliding
# row, the rest of the table stands.
resolved = False
for candidate in list(kwargs):
trial = dict(kwargs)
trial.pop(candidate)
try:
settings = Settings(**trial)
except ValidationError:
continue
skipped.append(
(
candidate,
f"cross-field collision with env/base {bad}: {err['msg']} "
"— override skipped, env value for the other field stands",
)
)
kwargs = trial
resolved = True
break
if not resolved:
# Nothing to drop (no overrides at all) or no single drop
# heals the set: the env base alone would boot (pre-phase
# proof), so return it — overrides skipped, boot intact.
skipped.append(
(
bad,
f"unresolvable cross-field collision: {err['msg']} — "
"all overrides skipped, env values in effect",
)
)
settings = Settings()
break
for key, reason in skipped:
logger.error("settings override ignored key=%s reason=%s", key, reason)
return settings, skipped
def resolve_settings() -> Settings:
"""The whole overlay: env base + kill switch + DB rows.
Never calls :func:`app.config.get_settings` (recursion guard — that
resolver delegates HERE): ``Settings()`` is the env-only entry
point, and the kill switch short-circuits before any DB access.
"""
if not enabled():
return Settings()
settings, _skipped = merge(load_overrides())
return settings
def register_invalidator(fn: Callable[[], None]) -> None:
"""Register a runtime reset hook (task 03: the chat LLM client +
semaphore). Hooks run on every :func:`app.config.invalidate_settings`
— after the cache clear — so the next resolution rebuilds the
singletons from the fresh settings."""
_invalidators.append(fn)
def run_invalidators() -> None:
"""Run the registered reset hooks, in order. A hook that raises is
logged, never propagated (a failed reset must not undo a
successful save — LOCKED A5's "the save stands")."""
for fn in list(_invalidators):
try:
fn()
except Exception:
logger.exception(
"settings invalidator failed — the save stands, this "
"runtime reset did not run"
)
def reset_state_for_tests() -> None:
"""Clear the module engine + the once-per-process flags + the hook
list (the unit suites flip the kill switch and stub the engine)."""
global _engine, _warned_db_failure, _debugged_missing_table
_engine = None
_warned_db_failure = False
_debugged_missing_table = False
_invalidators.clear()
+37 -1
View File
@@ -20,7 +20,9 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse from starlette.responses import FileResponse
from app.api.admin_settings import router as admin_settings_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.chat import reset_runtime_caches
from app.api.chat import router as chat_router from app.api.chat import router as chat_router
from app.api.chat_images import router as chat_images_router from app.api.chat_images import router as chat_images_router
from app.api.chats import ( from app.api.chats import (
@@ -42,7 +44,7 @@ from app.api.suggestions import router as suggestions_router
from app.api.sync import router as sync_router from app.api.sync import router as sync_router
from app.api.tokens import router as tokens_router from app.api.tokens import router as tokens_router
from app.api.ui_settings import router as ui_settings_router from app.api.ui_settings import router as ui_settings_router
from app.config import get_settings from app.config import get_settings, invalidate_settings
from app.core.auth import ensure_admin_configured from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching from app.core.caching import configure_caching
from app.core.compression import SelectiveGZipMiddleware from app.core.compression import SelectiveGZipMiddleware
@@ -50,6 +52,8 @@ from app.core.debugging import configure_debugging
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.core.response_budget import ResponseBudgetMiddleware from app.core.response_budget import ResponseBudgetMiddleware
from app.core.security_headers import SecurityHeadersMiddleware from app.core.security_headers import SecurityHeadersMiddleware
from app.core.settings_overlay import enabled as settings_db_enabled
from app.core.settings_overlay import load_overrides, register_invalidator
configure_logging() configure_logging()
configure_debugging() configure_debugging()
@@ -97,6 +101,33 @@ def create_app() -> FastAPI:
# variable(s) — the app never starts in a half-authenticated state. # variable(s) — the app never starts in a half-authenticated state.
ensure_admin_configured(settings) ensure_admin_configured(settings)
# Phase 168 (task 02, D2/D3): prime the DB-over-env resolver now
# that auth is verified. The module-level ``settings`` above stays
# the pre-boot value the middleware wiring below uses (LOCKED A4:
# those boot-wired settings apply on the NEXT boot); every consumer
# that calls get_settings() from here on resolves DB-over-env — no
# restart. invalidate_settings() runs the runtime reset hooks
# (task 03) and clears the cache; a boot-time DB miss is fail-soft
# (one WARNING, env values in effect) and the next resolution
# retries, so the prime can never cache an env-only view forever.
invalidate_settings()
if settings_db_enabled():
get_settings()
logger.info("settings overrides loaded count=%d", len(load_overrides()))
else:
get_settings()
logger.info("settings overrides loaded source=env-only disabled")
# Phase 168 (task 03): the settings panel's Save must retune the
# running process, and app/core must never import app/api (PLAN §3's
# direction) — so create_app(), the composition root, wires the
# reset hook. It runs on every invalidate_settings() after the cache
# clear: the next chat turn rebuilds the shared LLMClient and the
# concurrency semaphore from the CURRENT settings (no restart), while
# in-flight turns keep the client they started with (A12 semantics).
register_invalidator(reset_runtime_caches)
logger.info("settings invalidator registered name=chat_runtime_caches")
app = FastAPI(title=settings.app_name, version=settings.app_version) app = FastAPI(title=settings.app_name, version=settings.app_version)
# Signed single-admin session cookie (Starlette middleware, itsdangerous # Signed single-admin session cookie (Starlette middleware, itsdangerous
@@ -134,6 +165,10 @@ def create_app() -> FastAPI:
# single ui_settings row — the Theme tab's persistence) — admin-only # single ui_settings row — the Theme tab's persistence) — admin-only
# (router-wide require_admin; anonymous AND token users stay 403). # (router-wide require_admin; anonymous AND token users stay 403).
app.include_router(ui_settings_router, prefix="/api") app.include_router(ui_settings_router, prefix="/api")
# Phase 168 (task 04): the admin settings panel surface (read/write
# the app_settings overrides — DB-over-env, live on Save) — admin-only
# (router-wide require_admin; a token user stays 403).
app.include_router(admin_settings_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency. # Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the # /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page. # page route below, registered without a prefix) is the page.
@@ -197,6 +232,7 @@ def create_app() -> FastAPI:
"/history.html", "/history.html",
"/tokens.html", # phase 79 task 06: the Tokens view "/tokens.html", # phase 79 task 06: the Tokens view
"/theme.html", # phase 91 task 04: the Theme view (shell route) "/theme.html", # phase 91 task 04: the Theme view (shell route)
"/settings.html", # phase 168 task 05: the Settings view (shell route)
), ),
) )
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
+48
View File
@@ -70,6 +70,13 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
``lite`` summary the drill-down ``lite`` summary the drill-down
``ls`` shows next to each folder (phase 94; ``ls`` shows next to each folder (phase 94;
``folder_path = ""`` = the source root). ``folder_path = ""`` = the source root).
* ``app_settings`` — one row per admin setting override (phase
168, TODO.md L3): the Settings panel persists
what used to be ``.env``-only ``BOR_*`` knobs
here, keyed by the BARE ``Settings`` field
name, the value in its env-native string form;
the row WINS over the environment (resolved
inside ``app.config.get_settings``).
""" """
from __future__ import annotations from __future__ import annotations
@@ -649,3 +656,44 @@ class FolderSummary(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
class AppSetting(Base):
"""One admin setting override (phase 168, task 01).
``TODO.md`` L3: the admin Settings panel persists what used to be
``.env``-only knobs here, and the row WINS over the environment
(resolved by ``app.core.settings_overlay``, consumed by
``app.config.get_settings``). The narrow key/value shape is the
whole design (phase 168 D1): one row per setting, the BARE field
name as the key, the value in the SAME textual form the env var
would carry — so "anything in .env" is editable for all 75
settings with ZERO per-field DDL (no typed columns, no wide row),
and coercion/validation stays in exactly one place (the pydantic
validators, phase 168 A5). A DELETE of the row is the reset
("use the env value again") — never a NULL/empty value, so the
resolver never has to distinguish "no override" from "override to
empty".
No index beyond the PK: the whole table is read by primary key
order in ONE ``SELECT`` per settings resolution (the
``ui_settings`` "display fact, never a query surface" note, 0027).
"""
__tablename__ = "app_settings"
#: The BARE ``Settings`` field name (``relevance_threshold``) — never
#: the ``BOR_…`` form: the prefix is presentation
#: (``"BOR_" + key.upper()``), so a stored prefix could double up.
key: Mapped[str] = mapped_column(String(100), primary_key=True)
#: The value in the SAME textual form the env var would carry
#: (``"0.55"``, ``"true"``, ``"md,txt"``, a JSON array for the one
#: ``list[str]`` field) — coercion and validation belong to
#: pydantic, in exactly one place (phase 168 D1/A5). NOT NULL:
#: "clear the override" is a DELETE, never an empty value.
value: Mapped[str] = mapped_column(Text, nullable=False)
#: When the admin last saved it (the ``folder_summaries`` /
#: ``kb_overview.updated_at`` house style: server-side now()).
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+90
View File
@@ -1990,3 +1990,93 @@ class UiSettingsOut(BaseModel):
accent_bg: str accent_bg: str
accent_ink: str accent_ink: str
accent_line: str accent_line: str
class AdminSettingItem(BaseModel):
"""One row of the admin settings catalogue
(``GET``/``PUT``/``DELETE /api/admin/settings[/{key}]``, phase 168,
task 04).
One entry per ``Settings`` field (75 today — the catalogue is
completeness-pinned, ``tests/unit/test_settings_catalog.py``), in
``GROUPS`` order: the field's metadata (``group`` + ``label`` + the
three capability flags), where its EFFECTIVE value comes from
(``source``: ``db`` = an overridable row is in effect, ``env`` = the
``BOR_*`` var is in the process environment, ``default`` = the code
default), and the value itself — ``value`` in the SAME env-native
string form the env var would carry (bool → ``"true"``/``"false"``,
the one ``list[str]`` field → a JSON array, everything else
``str()``; the stringification lives in the router, D1), ``None``
while masked. LOCKED A4: a ``secret`` field is write-only —
``masked=True``, ``value=None``, ``db_value=None`` and
``has_value`` (is a non-empty value in effect at all) are all the
read side ever reports; the plaintext appears in no response.
"""
key: str
env_var: str
group: str
label: str
type: Literal["int", "float", "bool", "str", "json"]
secret: bool
restart_required: bool
overridable: bool
source: Literal["db", "env", "default"]
#: The effective value in env-native string form (``None`` masked).
value: str | None
#: Locked A4: ``True`` for a write-only field — ``value`` is ``None``
#: while masked and the plaintext is in no response.
masked: bool
#: For a masked field: is a non-empty value in effect at all.
has_value: bool
#: An (allowed) row exists in ``app_settings`` for this key.
has_override: bool
#: The stored string while it is a stored non-secret override; ``None``
#: otherwise (a masked or non-overridable row never exposes its text).
db_value: str | None
#: The code default, stringified (``""`` for a masked field).
default: str
class AdminSettingsTotals(BaseModel):
"""The catalogue's footer counts (phase 168, task 04): how many
settings there are and how many an ``app_settings`` row currently
overrides (in effect — ``source == "db"``)."""
count: int
overridden: int
class AdminSettingsOut(BaseModel):
"""``GET /api/admin/settings`` response (phase 168, task 04): the
whole catalogue — one :class:`AdminSettingItem` per ``Settings``
field, in ``GROUPS`` order (the stable order the E2E pins
against) — plus the footer counts and the ``BOR_SETTINGS_DB``
state: while ``overlay_enabled`` is ``false`` the panel shows a
banner instead of pretending a save took effect (the kill switch
disables the mechanism, not the panel — D6)."""
items: list[AdminSettingItem]
totals: AdminSettingsTotals
overlay_enabled: bool
class AdminSettingIn(BaseModel):
"""``PUT /api/admin/settings/{key}`` body (phase 168, task 04):
always a string — the value in the env-native string form (D1
stores strings; coercion and validation belong to pydantic's
``Settings`` validators, in exactly one place, A5). An empty
string for a non-secret field is a legitimate value (e.g. clearing
``git_sources``); an empty string for a SECRET field is a 422 from
the router (an empty API key / session secret is a self-lockout —
``DELETE`` is the explicit clear)."""
value: str
class AdminSettingsCleared(BaseModel):
"""``DELETE /api/admin/settings`` response (phase 168, task 04):
how many ``app_settings`` rows the "Clear all overrides" action
deleted."""
cleared: int
+27
View File
@@ -11,3 +11,30 @@ date,script,model,mode,gate_status,turns,answered,caps,tool_turns,emitted,execut
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,9,9,9,113.7,9,9,10,,,, 2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,9,9,9,113.7,9,9,10,,,,
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,7,7,7,112.8,7,7,10,,,, 2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,7,7,7,112.8,7,7,10,,,,
2026-09-06,chat,turbo,derived,FAIL,10,10,0,5,5,5,5,97.2,5,5,10,,,, 2026-09-06,chat,turbo,derived,FAIL,10,10,0,5,5,5,5,97.2,5,5,10,,,,
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.2,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.6,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,1.4,1,0,1,rate,35.6 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.6,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,1.4,1,0,1,rate,35.5 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.6,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,1.4,1,0,1,rate,35.6 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.1,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.3,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.8,1,0,1,rate,59.1 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.4,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.8,1,0,1,rate,61.4 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.3,1,0,1,margin,0.217,quality,43
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.9,1,0,1,rate,57.4 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.2,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.3,1,0,1,margin,0.283,quality,56
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.5,1,0,1,rate,109.7 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.1,1,0,1,margin,0.283,quality,56
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.4,1,0,1,rate,123.3 vec/s,bad_vectors,0
2026-10-09,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.0,1,0,1,actual_dim,768,expected_dim,768
2026-10-09,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.2,1,0,1,margin,0.283,quality,56
2026-10-09,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.4,1,0,1,rate,119.0 vec/s,bad_vectors,0
1 date script model mode gate_status turns answered caps tool_turns emitted executed contract wall_s contract_denom executed_denom tool_turns_denom extra1_col extra1_val extra2_col extra2_val
11 2026-09-06 chat turbo fixture PASS 10 10 0 7 9 9 9 113.7 9 9 10
12 2026-09-06 chat turbo fixture PASS 10 10 0 7 7 7 7 112.8 7 7 10
13 2026-09-06 chat turbo derived FAIL 10 10 0 5 5 5 5 97.2 5 5 10
14 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.2 1 0 1 actual_dim 768 expected_dim 768
15 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.6 1 0 1 margin 0.217 quality 43
16 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 1.4 1 0 1 rate 35.6 vec/s bad_vectors 0
17 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
18 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.6 1 0 1 margin 0.217 quality 43
19 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 1.4 1 0 1 rate 35.5 vec/s bad_vectors 0
20 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
21 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.6 1 0 1 margin 0.217 quality 43
22 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 1.4 1 0 1 rate 35.6 vec/s bad_vectors 0
23 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.1 1 0 1 actual_dim 768 expected_dim 768
24 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.3 1 0 1 margin 0.217 quality 43
25 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.8 1 0 1 rate 59.1 vec/s bad_vectors 0
26 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
27 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.4 1 0 1 margin 0.217 quality 43
28 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.8 1 0 1 rate 61.4 vec/s bad_vectors 0
29 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
30 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.3 1 0 1 margin 0.217 quality 43
31 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.9 1 0 1 rate 57.4 vec/s bad_vectors 0
32 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.2 1 0 1 actual_dim 768 expected_dim 768
33 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.3 1 0 1 margin 0.283 quality 56
34 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.5 1 0 1 rate 109.7 vec/s bad_vectors 0
35 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
36 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.1 1 0 1 margin 0.283 quality 56
37 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.4 1 0 1 rate 123.3 vec/s bad_vectors 0
38 2026-10-09 embed embed dimension PASS 1 1 0 0 0 0 1 0.0 1 0 1 actual_dim 768 expected_dim 768
39 2026-10-09 embed embed cosine PASS 1 1 0 0 0 0 1 0.2 1 0 1 margin 0.283 quality 56
40 2026-10-09 embed embed speed PASS 1 1 0 0 0 0 1 0.4 1 0 1 rate 119.0 vec/s bad_vectors 0
+7
View File
@@ -226,6 +226,13 @@ export async function initSharedHeader() {
// no-op. A token user (role "user") never sees it. // no-op. A token user (role "user") never sees it.
const navTheme = document.querySelector("#nav-theme"); const navTheme = document.querySelector("#nav-theme");
if (navTheme) navTheme.hidden = !admin; if (navTheme) navTheme.hidden = !admin;
// Phase 168 (task 05): the Settings nav link (the shell's eighth view
// — the phase-34 one-bar contract ships it on every page's nav) —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the Theme link above. Null-safe: a page without the link is a
// no-op. A token user (role "user") never sees it.
const navSettings = document.querySelector("#nav-settings");
if (navSettings) navSettings.hidden = !admin;
// Phase 34: the steering panel (phase 15) is module-owned. The // Phase 34: the steering panel (phase 15) is module-owned. The
// navbar #steering-toggle was removed at owner request (2026-08-28) // navbar #steering-toggle was removed at owner request (2026-08-28)
// — the panel ships hidden and is only kept fresh. Admin: refresh // — the panel ships hidden and is only kept fresh. Admin: refresh
+5
View File
@@ -82,6 +82,7 @@ const VIEW = {
"/history.html": "history", // phase 76 task 03: the History view (saved chats) "/history.html": "history", // phase 76 task 03: the History view (saved chats)
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens) "/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding) "/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
}; };
/* The nav-link href the router stamps active for each view (the /* The nav-link href the router stamps active for each view (the
@@ -94,6 +95,7 @@ const VIEW_PATH = {
history: "/history.html", history: "/history.html",
tokens: "/tokens.html", tokens: "/tokens.html",
theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only) theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only)
settings: "/settings.html", // phase 168 task 05: the Settings view (admin-only)
}; };
/* The lazy view modules — ONLY the non-chat views (chat needs no /* The lazy view modules — ONLY the non-chat views (chat needs no
@@ -108,6 +110,7 @@ const VIEW_MODULES = {
history: () => import("./history.js"), // phase 76 task 03 history: () => import("./history.js"), // phase 76 task 03
tokens: () => import("./tokens.js"), // phase 79 task 06 tokens: () => import("./tokens.js"), // phase 79 task 06
theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor) theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor)
settings: () => import("./settings.js"), // phase 168 task 05 (the settings panel)
}; };
/* Per-view document.head values, carried over from the old pages' /* Per-view document.head values, carried over from the old pages'
@@ -122,6 +125,7 @@ const TITLES = {
history: "Saved chats · Brain of Reese", // old history.html <title> history: "Saved chats · Brain of Reese", // old history.html <title>
tokens: "Access tokens · Brain of Reese", tokens: "Access tokens · Brain of Reese",
theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new
settings: "Settings · Brain of Reese", // phase 168 task 05: no old page — the view is new
}; };
const DESCRIPTIONS = { const DESCRIPTIONS = {
chat: chat:
@@ -135,6 +139,7 @@ const DESCRIPTIONS = {
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta "Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
tokens: "Generate and revoke the API tokens that let people use the app.", tokens: "Generate and revoke the API tokens that let people use the app.",
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.", theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
settings: "Edit any application setting — a saved value wins over the environment, live on save; reset hands control back.",
}; };
/* The brand-resolved display name (phase 39 — brand.js is the single /* The brand-resolved display name (phase 39 — brand.js is the single
+642
View File
@@ -0,0 +1,642 @@
/* Brain of Reese — Settings view module (phase 168, task 05): the
* admin settings panel — "edit anything in .env and save it to the
* database. The database value should override the env var. The
* change should take effect on clicking 'save'" (TODO.md L3).
*
* The phase-76 shell-view-module contract (the theme.js / tokens.js
* shape): the router (assets/router.js) lazy-imports this module on
* FIRST show of #view-settings and calls mount(root) ONCE (mount-once,
* hide-forever — root is the view's <section id="view-settings">,
* every DOM lookup scoped to it). The initSharedHeader() call never
* happens here: in the shell the shared header boots exactly once,
* via the chat module (app.js) at shell boot.
*
* What the panel does:
*
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
* header.js exports, zero extra requests): admin hides
* #settings-gate and reveals #settings-content; anonymous /
* token-user keeps the gate (the #nav-settings link is already
* hidden by header.js — the gate is the DIRECT-URL case, the
* #tokens-gate pattern). No /api/admin/settings request is ever
* made outside the admin branch (the router 403s them — the
* phase-79 contract).
* • load — GET /api/admin/settings → render the 75-row catalogue
* in the SERVER's order (GROUPS order — the stable order the
* E2E pins against): one <tr> per setting (the group band rows
* are created per group) + the ship-rebuilt no-match row, all
* through createElement + textContent (this file never builds
* HTML — the XSS-safe-by-construction house rule). A failed
* fetch keeps the last rendered catalogue (never a blanked panel)
* and shows the house error line.
* • rows — the Setting cell (the label in a .cell-clip ellipsis
* — the phase-156 contract with the full text in the control's
* aria-label — + the BOR_* name in <code class="settings-env">
* + the literal-text `restart required` .settings-flag for the
* boot-wired fields (text, never color alone, B5, with the
* title/aria-describedby copy explaining when it applies)),
* the Value cell (the control by the server's `type`: int/float
* → <input type=number> (step="any" for float), bool → a
* true/false <select>, json → a spellcheck=false <textarea>
* (the one list[str] field, K2), str → <input type=text> (a
* <textarea rows=2> for the CSV/path fields whose value exceeds
* 40 chars), secret → <input type=password
* autocomplete=new-password placeholder="•••••• (stored)"> with
* an aria-label naming the env var — an EMPTY secret input
* means "leave unchanged" (never an accidental wipe); every
* control is id=set-<key> with a <label for>), the Source cell
* (the literal-text DB / env / default badge — the color is
* decoration, B5), and the Actions cell (the per-row Reset —
* disabled with a title when there is nothing to reset or the
* field is read-only, LOCKED A3).
* • dirty tracking — module-state Map<key, value>: an input/
* change marks the row (.is-dirty + the "N unsaved changes"
* hint) and enables #settings-save; a row that returns to its
* served value un-marks.
* • Save — the §7.4 never-stale lifecycle (disabled + aria-busy +
* "Saving…", re-enabled on success AND failure — a click can
* never leave the button stuck): for each dirty row in DOM
* order PUT /api/admin/settings/<key> with { value }; STOP AT
* THE FIRST FAILURE (K4) — its server detail lands in
* #settings-error (role=alert), the catalogue re-GETs so the
* badges show the truth, the remaining rows' edits are
* re-applied to the re-rendered controls (they stay in the form
* for correction), and focus goes to the offending input. All
* good → one #settings-status line `Saved N settings.` (then
* role=status) — a saved restart-required row appends "—
* applies on the next restart" (the owner is never told
* "saved" and then sees nothing change) — + a re-GET so every
* badge/value is the settled server state.
* • Reset — per row: DELETE /api/admin/settings/<key> → the
* status line `BOR_X is back to the environment value.` + a
* re-GET.
* • Clear all overrides — the house two-step inline confirm
* (the history-confirm-* classes, focus moves to the confirm
* button, Escape / No cancels) → DELETE /api/admin/settings →
* the status line `Cleared N overrides.` (N from the
* response) + a re-GET.
* • search — #settings-filter: a case-insensitive substring over
* the label + BOR_* name cells, applied on input with NO fetch
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
* load (the query lives in the input — it survives re-shows,
* the phase-77 contract). A group with no visible rows hides
* its group row; zero matches with a non-empty query → one
* no-match row `No settings match "<query>"` (textContent).
* • the kill-switch banner — while overlay_enabled is false
* (BOR_SETTINGS_DB=0, D6) the #settings-disabled alert explains
* that saving stores the value but it will not take effect
* (the mechanism is off, not the panel).
*
* Every value is rendered with textContent — this file never builds
* HTML (the XSS-safe-by-construction house rule; a full-file source
* pin enforces it).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const gateEl = root.querySelector("#settings-gate");
const contentEl = root.querySelector("#settings-content");
const disabledBanner = root.querySelector("#settings-disabled");
const statusEl = root.querySelector("#settings-status");
const errorEl = root.querySelector("#settings-error");
const filter = root.querySelector("#settings-filter");
const tbody = root.querySelector("#settings-tbody");
const saveBtn = root.querySelector("#settings-save");
const clearAllBtn = root.querySelector("#settings-clear-all");
const dirtyHint = root.querySelector("#settings-dirty");
const SAVE_LABEL = "Save settings";
/* Module state (the phase-76 mount-once contract — it survives
every switch): the last settled catalogue (the row meta for the
save's restart check), the pending edits (key → the value being
edited), and the in-flight save flag (the §7.4 guard). */
let lastItems = [];
const dirty = new Map();
let saveInFlight = false;
let clearAllEsc = null; // the armed Escape handler (null = disarmed)
/* The no-match row (rebuilt into the tbody on every render — the
<td> text is JS-filled; it is never a data row). */
const noMatchRow = document.createElement("tr");
noMatchRow.className = "settings-no-match";
noMatchRow.hidden = true;
const noMatchTd = document.createElement("td");
noMatchTd.colSpan = 4;
noMatchRow.appendChild(noMatchTd);
/* ---------- feedback lines (never stale, §7.4) ---------- */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function showError(message) {
if (!errorEl) return;
errorEl.textContent = message; // textContent — the server detail is data
errorEl.hidden = false;
}
function clearError() {
if (!errorEl) return;
errorEl.textContent = "";
errorEl.hidden = true;
}
/* FastAPI error bodies: a string detail (the house 422s — the
detail names the offending field) or the validation-error array
(the first entry's msg). Same extraction as theme.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- catalogue render (createElement + textContent only) ---------- */
const dataRows = () =>
tbody ? [...tbody.querySelectorAll("tr.settings-row")] : [];
function makeControl(item) {
let control;
if (item.secret) {
/* LOCKED A4: the secret is write-only — the input starts
EMPTY (the plaintext never leaves the server); an empty
value means "leave unchanged" (never an accidental wipe). */
control = document.createElement("input");
control.type = "password";
control.setAttribute("autocomplete", "new-password");
control.placeholder = "•••••• (stored)";
} else if (item.type === "int") {
control = document.createElement("input");
control.type = "number";
control.value = item.value;
} else if (item.type === "float") {
control = document.createElement("input");
control.type = "number";
control.step = "any";
control.value = item.value;
} else if (item.type === "bool") {
control = document.createElement("select");
for (const opt of ["true", "false"]) {
const option = document.createElement("option");
option.value = opt;
option.textContent = opt;
control.appendChild(option);
}
control.value = item.value;
} else if (item.type === "json") {
/* The one list[str] field (K2) — edited as a JSON array in a
textarea (the server's env-native string form, verbatim). */
control = document.createElement("textarea");
control.spellcheck = false;
control.rows = 2;
control.value = item.value;
} else if (item.type === "str" && (item.value || "").length > 40) {
/* A CSV / path field with a long value — the two-row
textarea (no single-line clip for 200-char lists). */
control = document.createElement("textarea");
control.rows = 2;
control.value = item.value;
} else {
control = document.createElement("input");
control.type = "text";
control.setAttribute("autocomplete", "off");
control.value = item.value;
}
control.id = "set-" + item.key;
control.classList.add("settings-control");
/* The full (unclipped) label stays reachable: the .cell-clip
ellipsis is visual only (the phase-156 contract), and the
secret's aria-label names the env var (the house pattern for
table-hosted inputs — the Setting cell's label is the
visible <label for>). */
control.setAttribute(
"aria-label",
item.secret ? item.env_var : `${item.label} — ${item.env_var}`,
);
return control;
}
function makeRow(item) {
const tr = document.createElement("tr");
tr.className = "settings-row";
tr.dataset.key = item.key;
/* The Setting cell: the label (the .cell-clip ellipsis — the
full text is the label's own textContent + the control's
aria-label), the BOR_* name (presentation only, D1), and —
when boot-wired (LOCKED A4) — the literal-text flag with
the when-it-applies copy (title + the shared
aria-describedby target). */
const nameTd = document.createElement("td");
nameTd.className = "settings-name-cell";
const label = document.createElement("label");
label.htmlFor = "set-" + item.key;
const clip = document.createElement("span");
clip.className = "cell-clip";
clip.textContent = item.label;
label.appendChild(clip);
const env = document.createElement("code");
env.className = "settings-env";
env.textContent = item.env_var;
nameTd.append(label, env);
if (item.restart_required) {
const flag = document.createElement("span");
flag.className = "settings-flag";
flag.textContent = "restart required"; // text, never color alone (B5)
flag.title =
"Applies on the next restart — the app reads this setting once at boot";
flag.setAttribute("aria-describedby", "settings-restart-desc");
nameTd.appendChild(flag);
}
tr.appendChild(nameTd);
/* The Value cell: the control by type (see makeControl). */
const valueTd = document.createElement("td");
valueTd.className = "settings-value-cell";
const control = makeControl(item);
valueTd.appendChild(control);
tr.appendChild(valueTd);
/* The Source cell: the literal-text badge (db → "DB", env,
default — the color is decoration, B5). */
const srcTd = document.createElement("td");
const src = document.createElement("span");
src.className =
"settings-src " +
({ db: "is-db", env: "is-env", default: "is-default" })[item.source];
src.textContent = item.source === "db" ? "DB" : item.source;
srcTd.appendChild(src);
tr.appendChild(srcTd);
/* The Actions cell: the per-row Reset — disabled with a title
when there is NOTHING to reset (no saved override) or the
field is READ-ONLY (LOCKED A3: the engine is built from it /
the dim is fixed at table creation). */
const actTd = document.createElement("td");
actTd.className = "settings-actions-cell";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "settings-reset";
reset.textContent = "Reset";
reset.setAttribute("aria-label", `Reset ${item.env_var}`);
if (!item.overridable) {
reset.disabled = true;
reset.title = `read-only — ${item.env_var} is not settable from the database`;
} else if (!item.has_override) {
reset.disabled = true;
reset.title = "no saved override — the environment value is in effect";
}
reset.addEventListener("click", () => void resetRow(item.key, item.env_var));
actTd.appendChild(reset);
tr.appendChild(actTd);
/* Dirty tracking (input + change — the select/change pair). */
control.addEventListener("input", () => markDirty(item, control));
control.addEventListener("change", () => markDirty(item, control));
return tr;
}
/* GET /api/admin/settings → render the groups in the server's
order (re-entrant: the tbody is REPLACED — never a duplicate
set — and the no-match row is re-appended). A FAILED load
returns before the render (the last catalogue stays) and shows
the house error line. */
function render(items) {
if (!tbody) return;
const rows = [];
let currentGroup = null;
for (const item of items) {
if (item.group !== currentGroup) {
currentGroup = item.group;
const groupRow = document.createElement("tr");
groupRow.className = "settings-group-row";
const th = document.createElement("th");
th.colSpan = 4;
th.scope = "colgroup";
th.textContent = item.group;
groupRow.appendChild(th);
rows.push(groupRow);
}
rows.push(makeRow(item));
}
rows.push(noMatchRow);
tbody.replaceChildren(...rows);
}
async function loadSettings() {
let r;
try {
r = await fetch("/api/admin/settings");
} catch {
showError("Couldn't load the settings — is the app reachable?");
return false;
}
if (!r.ok) {
showError("Couldn't load the settings — try again.");
return false;
}
let data;
try {
data = await r.json();
} catch {
showError("Couldn't load the settings — try again.");
return false;
}
clearError();
lastItems = data.items;
/* The kill-switch banner (D6): the mechanism is off, not the
panel — a save stores the value but it will not take effect. */
if (disabledBanner) disabledBanner.hidden = data.overlay_enabled;
render(data.items);
/* A failed batch keeps the remaining edits in the form (K4):
re-apply every pending value to the re-rendered control and
re-mark the row. */
for (const [key, value] of dirty) {
const control = root.querySelector("#set-" + key);
if (!control) continue;
control.value = value;
const row = control.closest("tr");
if (row) row.classList.add("is-dirty");
}
applyFilter();
updateDirtyUI();
return true;
}
/* ---------- dirty tracking (module state) ---------- */
function markDirty(item, control) {
/* The served value: the effective value in env-native string
form — for a MASKED field it is "" (an empty input means
"leave unchanged", never an accidental wipe). */
const served = item.secret ? "" : item.value ?? "";
const value = control.value;
if (value === served) {
dirty.delete(item.key);
} else {
dirty.set(item.key, value);
}
updateDirtyUI();
}
function updateDirtyUI() {
for (const row of dataRows()) {
row.classList.toggle("is-dirty", dirty.has(row.dataset.key));
}
if (dirtyHint) {
dirtyHint.textContent = dirty.size
? `${dirty.size} unsaved changes`
: "";
}
if (saveBtn && !saveInFlight) saveBtn.disabled = dirty.size === 0;
}
/* ---------- the client-side search (phase-101 D4 contract) ----------
* Case-insensitive substring over the label + BOR_* name cells,
* applied on `input` with NO fetch and re-applied after every
* load (the query lives in the input — it survives re-shows).
* A group with no visible rows hides its group row; zero matches
* with a non-empty query → the one no-match row (textContent). */
function applyFilter() {
const query = (filter ? filter.value : "").trim().toLowerCase();
const children = tbody ? [...tbody.children] : [];
let visible = 0;
let currentGroupRow = null;
const groupVisible = new Map();
for (const tr of children) {
if (tr === noMatchRow) continue; // the state row is never a data row
if (tr.classList.contains("settings-group-row")) {
currentGroupRow = tr;
groupVisible.set(tr, false);
continue;
}
const nameCell = tr.querySelector(".settings-name-cell");
const labelEl = nameCell ? nameCell.querySelector("label") : null;
const envEl = nameCell
? nameCell.querySelector(".settings-env")
: null;
const haystack =
((labelEl ? labelEl.textContent : "") +
" " +
(envEl ? envEl.textContent : "")).toLowerCase();
const show = query === "" || haystack.includes(query);
tr.hidden = !show;
if (show) visible += 1;
if (currentGroupRow) groupVisible.set(currentGroupRow, true);
}
for (const [groupRow, hasVisible] of groupVisible) {
groupRow.hidden = !hasVisible;
}
const noMatch = query !== "" && visible === 0;
noMatchRow.hidden = !noMatch;
if (noMatch) {
noMatchTd.textContent = `No settings match "${
filter ? filter.value.trim() : ""
}".`;
}
}
/* ---------- Save (dirty rows only — stop at the first failure,
K4; the §7.4 never-stale lifecycle) ---------- */
async function saveSettings() {
if (!dirty.size || saveInFlight) return;
saveInFlight = true;
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.setAttribute("aria-busy", "true");
saveBtn.textContent = "Saving…";
}
clearError();
const saved = [];
let failure = null;
for (const row of dataRows()) {
const key = row.dataset.key;
if (!key || !dirty.has(key)) continue;
const value = dirty.get(key);
try {
const r = await fetch(`/api/admin/settings/${key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!r.ok) {
failure = { key, r };
break; // stop at the first failure (K4)
}
saved.push(key);
dirty.delete(key);
} catch {
failure = { key, r: null };
break; // the app is unreachable — stop
}
}
if (failure) {
/* The 422 detail (it names the field) or the house network
line; the catalogue re-GETs first (the badges show the
truth, the remaining edits are re-applied — K4), and the
error is shown AFTER the re-GET (the re-GET's clearError
must not wipe it — the previous success line is cleared
with it, so the stale "Saved N settings." never lingers,
the §7.4 never-stale contract). Focus goes to the
offending input. */
await loadSettings();
if (statusEl) statusEl.textContent = "";
showError(
failure.r
? await apiDetail(failure.r, "Couldn't save the settings — try again.")
: "Couldn't save the settings — is the app reachable?",
);
const input = root.querySelector("#set-" + failure.key);
if (input) input.focus();
} else {
/* One status line (role=status) — a saved restart-required
row says so (the owner is never told "saved" and then sees
nothing change). */
const anyRestart = saved.some(
(key) => (lastItems.find((i) => i.key === key) || {}).restart_required,
);
announce(
`Saved ${saved.length} settings.` +
(anyRestart ? " — applies on the next restart" : ""),
);
await loadSettings(); // every badge/value is the settled state
}
saveInFlight = false;
if (saveBtn) {
saveBtn.removeAttribute("aria-busy");
saveBtn.textContent = SAVE_LABEL;
saveBtn.disabled = dirty.size === 0;
}
}
/* ---------- the per-row Reset (DELETE the row → env wins) ---------- */
async function resetRow(key, envVar) {
let r;
try {
r = await fetch(`/api/admin/settings/${key}`, { method: "DELETE" });
} catch {
announce(`Couldn't reset ${envVar} — is the app reachable?`);
return;
}
if (!r.ok) {
announce(`Couldn't reset ${envVar} — try again.`);
return;
}
announce(`${envVar} is back to the environment value.`);
await loadSettings(); // the badge falls back to env/default
}
/* ---------- Clear all overrides (the house two-step inline
confirm — the history-confirm-* classes; focus to Yes; Escape
/ No cancels) ---------- */
function restoreClearAll() {
if (clearAllEsc) {
document.removeEventListener("keydown", clearAllEsc, true);
clearAllEsc = null;
}
const wrap = root.querySelector(".settings-confirm");
if (wrap) wrap.replaceWith(clearAllBtn);
if (clearAllBtn) clearAllBtn.focus();
}
async function confirmClearAll(yesBtn, restore) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let cleared = null;
try {
const r = await fetch("/api/admin/settings", { method: "DELETE" });
if (!r.ok) {
announce("Couldn't clear the overrides — try again.");
restore();
return;
}
const data = await r.json();
cleared = data.cleared;
} catch {
announce("Couldn't clear the overrides — is the app reachable?");
restore();
return;
}
restore();
announce(`Cleared ${cleared} overrides.`);
await loadSettings(); // every badge falls back to env/default
}
function showClearAllConfirm() {
if (!clearAllBtn) return;
const wrap = document.createElement("span");
wrap.className = "settings-confirm";
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Clear all overrides?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () => void confirmClearAll(yes, restoreClearAll));
no.addEventListener("click", restoreClearAll);
wrap.append(label, yes, no);
clearAllBtn.replaceWith(wrap);
yes.focus(); // the confirm pair takes over the focus
/* Escape cancels (the task contract — the capture phase so the
page's own Esc handlers never race it). */
clearAllEsc = (e) => {
if (e.key === "Escape") {
e.stopPropagation();
restoreClearAll();
}
};
document.addEventListener("keydown", clearAllEsc, true);
}
/* ---------- view boot (phase 168 task 05) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
* header uses (zero extra requests). Anonymous / token-user: the
* gate in, the content out — and NO /api/admin/settings request
* at all (the router 403s them — the phase-79 contract). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true;
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
/* Bindings — armed in the ADMIN branch only (like the refresh
listener): Save (the §7.4 lifecycle), Clear all (the two-step
confirm), the client-side search (NO fetch). */
if (saveBtn) saveBtn.addEventListener("click", () => void saveSettings());
if (clearAllBtn) clearAllBtn.addEventListener("click", showClearAllConfirm);
if (filter) filter.addEventListener("input", () => applyFilter());
/* Phase 77 (the re-show refresh contract): a user-initiated
re-show of this already-mounted view makes the router dispatch
bor:view-refresh on the section — re-run the load then (the
tab always shows the settled server state when re-shown; the
search query survives — it lives in the input). Armed ONLY
here, after the whoami gate passed. `started` flips true once
the first loadSettings() is made, so the listener can only
ever re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) void loadSettings();
});
started = true;
void loadSettings(); // the first load (the catalogue)
}
+238
View File
@@ -4646,6 +4646,232 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
padding: 0.45rem 0.8rem; padding: 0.45rem 0.8rem;
} }
/* ---------- Settings view (phase 168) ----------
The admin settings panel: ONE .table-wrap card (the phase-156
bounded scroll region — max-height: min(70vh, 64rem) — and the
sticky thead via the .table-wrap rule) holding the 75-row
catalogue (grouped, type-correct controls, literal-text source
badges — B5: text + color, never color alone), the client-side
search row, and the Save / Clear-all action row. Every color is
an EXISTING AA token (B3: no new palette variable, no new :root
token — the phase-156 rule): the badges are TEXT and the color
is decoration. Nothing animates, so prefers-reduced-motion needs
no new rule. */
.settings-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The catalogue: FULL-WIDTH (AGENTS.md rule 5 — the .git-sources-
table language: no skinny list), one cell per column, the
.table-wrap card + its bounded scroll + the sticky thead carry
the phase-156 contract. */
.settings-table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
font-size: 0.93rem;
}
.settings-table th, .settings-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.settings-table td { white-space: nowrap; }
/* The group band: the --surface row, ink text (15.3:1, AA). */
.settings-group-row th {
background: var(--surface);
color: var(--ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.settings-table tbody tr.settings-row:hover td { background: var(--bg); }
/* The Setting cell: the label (bold) over the BOR_* name (mono,
ink-soft 8.2:1 on --surface, AA). */
.settings-name-cell label { margin: 0; font-weight: 600; min-width: 0; }
.settings-env {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* The restart-required flag (LOCKED A4, boot-wired): the accent
STATE family (--accent-ink on --accent-bg 9.5:1, AA) — text +
color, never color alone (B5); the title / aria-describedby
carry the when-it-applies copy. */
.settings-flag {
display: inline-block;
margin-top: 0.25rem;
padding: 0.06rem 0.5rem;
border: 1px solid var(--accent-line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--accent-bg);
color: var(--accent-ink);
}
/* The Value cell: the type-correct control (number / select /
textarea / text / password) — the .token-label input language
(--line hairline, --surface fill, ink text 15.3:1, AA; the
values are data — mono) + the ≥44px target + the global
:focus-visible ring. */
.settings-control {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.85rem;
}
.settings-control:disabled { opacity: 0.6; }
/* The Source cell: the literal-text badge — the three is-*
variants are SOLID existing tokens (B3): DB is brand-ink on
brand-soft (9.0:1, AA), env is ink-soft on surface (8.2:1, AA;
the --line border keeps it visible on the card), default is
ink-soft on line (7.1:1, AA). The badge is TEXT — the color
is decoration (B5). */
.settings-src {
display: inline-block;
padding: 0.12rem 0.55rem;
border-radius: 999px;
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.settings-src.is-db { background: var(--brand-soft); color: var(--brand-ink); }
.settings-src.is-env { background: var(--surface); color: var(--ink-soft); border: 1px solid var(--line); }
.settings-src.is-default { background: var(--line); color: var(--ink-soft); }
/* A dirty row: the --brand left marker on the row's first cell
(an inset box-shadow — no layout shift) so "unsaved" survives
grayscale + the N-unsaved-changes hint (text, B5). */
.settings-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
/* The per-row Reset: the .token-revoke language (≥44px, --line
hairline, transparent fill, ink-soft, the err hover pair — it
hands control back to the environment) + the dimmed :disabled
(nothing to reset / read-only). */
.settings-reset {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.settings-reset:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.settings-reset:disabled { opacity: 0.5; cursor: default; }
/* The search row: the .token-create flex (wraps below 640px via
the media query below) + the #token-label input language. */
.settings-search {
display: flex;
align-items: center;
gap: 0.6rem;
}
.settings-search label { white-space: nowrap; }
#settings-filter {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
#settings-filter:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* The action row: Save (the .token-generate brand pill — the
§7.4 in-flight state is the dimmed :disabled + aria-busy) and
Clear all (the .token-revoke ghost — the destructive err hover
pair; the two-step confirm reuses the .history-confirm-* rules
unchanged). */
.settings-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
#settings-save {
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
#settings-save:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
#settings-save:disabled { opacity: 0.6; cursor: wait; }
#settings-clear-all {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
#settings-clear-all:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
/* The "N unsaved changes" hint (textContent only, aria-hidden):
the .tokens-status voice (mono, ink-soft). */
.settings-dirty {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--brand-ink);
}
/* The ONE status region (the .tokens-status mirror — the
min-height holds the layout so a line never reflows the table).
The error line (the .theme-error mirror). */
.settings-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
.settings-error {
display: block;
margin: 0;
min-height: 1.2em;
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
color: var(--err-ink);
}
/* The no-match row (the .tokens-empty-row language). */
.settings-no-match td {
padding: 0.9rem 1rem;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.85rem;
white-space: normal;
}
/* ---------- Shared page (phase 51, task 03) ---------- /* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked /shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shared page reads exactly like the 2026-08-29, TODO.md L6). The shared page reads exactly like the
@@ -6160,6 +6386,18 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.theme-actions { flex-direction: column; align-items: stretch; } .theme-actions { flex-direction: column; align-items: stretch; }
#theme-save { width: 100%; } #theme-save { width: 100%; }
.theme-reset { width: 100%; } .theme-reset { width: 100%; }
/* Phase 168 (task 05): the Settings panel squeezes — the search
row stacks (the label over the full-width input) and the action
row stacks (Save full width, Clear all under it; the ≥44px
targets are the global rules above — the stack keeps them on
phones). The actions cell wraps so the Reset control fits the
phone width (the table's own horizontal scroll covers the
columns). */
.settings-search { flex-direction: column; align-items: stretch; }
.settings-actions { flex-direction: column; align-items: stretch; }
#settings-save { width: 100%; }
#settings-clear-all { width: 100%; }
.settings-actions-cell { white-space: normal; }
/* Phase 51: the shared page squeezes like the chat column — the /* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the title and the note step down (the empty-state-title family); the
shell is full-width here (the 72rem cap never binds on a phone — shell is full-width here (the 72rem cap never binds on a phone —
+7
View File
@@ -72,6 +72,13 @@
test_nav_consistency pins the inventory parity). test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. --> Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). --> outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+134
View File
@@ -60,6 +60,13 @@
view). No mobile dropdown copy is needed: the link lives view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. --> in the SAME #app-nav element the hamburger opens. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Theme link above (the phase-34 one-bar
contract; the shell's eighth view). No mobile dropdown copy
is needed: the link lives in the SAME #app-nav element the
hamburger opens. A token user never sees it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). --> outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
@@ -1478,6 +1485,133 @@
</div> </div>
</section> </section>
<!-- Phase 168 (task 05): the Settings view — the shell's eighth
view (the #view-tokens skeleton, verbatim): the 72rem
.container, the anonymous sign-in gate (the #tokens-gate
shape), and the ship-hidden #settings-content (settings.js
reveals it for admin only). The catalogue's <tbody> elements
are created by settings.js (one per group, each opening with
a <tr class="settings-group-row">); the .table-wrap +
sticky-thead rules in styles.css carry the bounded-scroll +
sticky-header contract (the phase-156 catalogue language).
No <script> tag: the router lazy-imports settings.js
(rule 6 — everything local). -->
<section class="view" id="view-settings" hidden inert aria-label="Settings" tabindex="-1">
<div class="container settings-shell">
<div class="page-head">
<h1>Settings</h1>
<p class="page-sub">
Values saved here are stored in the database and win over
the server's environment variables; clearing one hands
control back to the environment. <code>.env</code> is never
written — the environment stays the base, the database row
wins.
</p>
</div>
<!-- Phase 168 (task 05): anonymous sign-in gate — the EXACT
#tokens-gate shape (phase 79) and the same .sources-gate
visual language: every setting is what the login locks.
Visible for anonymous, hidden for the admin (settings.js).
The gate's Sign in returns to the Settings view (the
header's ?next= convention; the static href is the no-JS
fallback). -->
<section class="sources-gate" id="settings-gate" aria-labelledby="settings-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="settings-gate-title">Sign in to change settings</h2>
<p class="sources-gate-sub">
Editing application settings is admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/settings.html">Sign in</a>
</section>
<!-- Phase 168 (task 05): the panel — SHIPS hidden
(anonymous-safe; the gate is what anonymous sees).
settings.js reveals it once the cached whoami says admin
(the #theme-content pattern) and renders the catalogue's
groups/rows from GET /api/admin/settings. -->
<div id="settings-content" hidden>
<!-- Shown when the server's overlay_enabled is false
(the BOR_SETTINGS_DB=0 kill switch — D6): the panel
stays editable, but a save cannot take effect. -->
<div class="kb-banner is-error" id="settings-disabled" role="alert" hidden>
DB overrides are switched off on the server
(<code>BOR_SETTINGS_DB=0</code>) — saving stores the
value but it will not take effect.
</div>
<!-- The ONE status region (the #tokens-status precedent):
settings.js sets textContent here — a save's outcome,
a reset's outcome, a clear-all's outcome, nothing else
(the error line has its own role=alert below). -->
<span class="settings-status" id="settings-status" role="status" aria-live="polite"></span>
<!-- The error line (the #theme-error precedent): the
server's 422 detail (it names the offending field)
and the house load-failure lines; the rows keep their
edits on failure. -->
<p class="settings-error" id="settings-error" role="alert" hidden></p>
<!-- The client-side filter (the phase-101 tokens D4
contract): a case-insensitive substring over the label
+ BOR_* name cells, applied on input with NO fetch,
re-applied after every re-load (the query survives
re-shows — the phase-77 contract). -->
<div class="settings-search">
<label for="settings-filter">Search settings</label>
<input
id="settings-filter"
type="search"
autocomplete="off"
>
</div>
<!-- The scroll card: the .table-wrap + sticky-th rules in
styles.css are already the bounded-scroll + sticky-
header contract (the phase-156 catalogue language).
The <tbody> elements are created by settings.js (one
per group, each opening with a settings-group-row). -->
<div class="table-wrap">
<table class="settings-table">
<caption class="visually-hidden">Application settings — the value in effect and where it comes from</caption>
<thead>
<tr>
<th scope="col">Setting</th>
<th scope="col">Value</th>
<th scope="col">Source</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="settings-tbody"></tbody>
</table>
</div>
<!-- The action row: Save (dirty rows only — ships disabled
until a row is edited; settings.js runs the §7.4
never-stale lifecycle), Clear all overrides (the house
two-step inline confirm, settings.js), and the
"N unsaved changes" hint (textContent only, aria-hidden
— the rows' .is-dirty state + the status line carry
the information). -->
<div class="settings-actions">
<button type="button" class="btn primary" id="settings-save" disabled>Save settings</button>
<button type="button" class="btn ghost" id="settings-clear-all">Clear all overrides</button>
<span class="settings-dirty" id="settings-dirty" aria-hidden="true"></span>
</div>
<!-- The shared description target for every
restart-required row's flag (aria-describedby). -->
<p class="visually-hidden" id="settings-restart-desc">
Applies on the next restart — the app reads this setting
once at boot, so the saved value takes effect when the
app next starts.
</p>
</div>
</div>
</section>
</main> </main>
<!-- Phase 79 (task 05): the in-app token gate — a body-level <!-- Phase 79 (task 05): the in-app token gate — a body-level
+7
View File
@@ -65,6 +65,13 @@
test_nav_consistency pins the inventory parity). test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. --> Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). --> outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+7
View File
@@ -66,6 +66,13 @@
test_nav_consistency pins the inventory parity). test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. --> Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). --> outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+1
View File
@@ -315,6 +315,7 @@ def run(args: argparse.Namespace) -> tuple[int, list[Row], list[str]]:
rows.append(measure(client, "tokens", "/api/tokens")) rows.append(measure(client, "tokens", "/api/tokens"))
rows.append(measure(client, "config", "/api/config")) rows.append(measure(client, "config", "/api/config"))
rows.append(measure(client, "whoami", "/api/whoami")) rows.append(measure(client, "whoami", "/api/whoami"))
rows.append(measure(client, "admin settings (catalogue)", "/api/admin/settings"))
corpus.append(f"sources={len(depth1.get('sources', []))}") corpus.append(f"sources={len(depth1.get('sources', []))}")
totals = depth1.get("totals") or {} totals = depth1.get("totals") or {}
+9
View File
@@ -35,6 +35,15 @@ SESSION_SECRET = "test-session-secret-0123456789abcdef0123456789abcdef"
os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD) os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET) os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
# Phase 168 (task 02): the DB-over-env overlay
# (app/core/settings_overlay) is pinned OFF for every in-process suite
# — the shared test DB must never retune the app under test (the same
# poison-guard reasoning as the phase-140 BOR_PDF_PICTORIAL pin in
# tests/e2e/conftest.py): a row left behind by one suite would
# silently change every later suite's settings. Tests that WANT the
# overlay set BOR_SETTINGS_DB=1 + get_settings.cache_clear() themselves.
os.environ.setdefault("BOR_SETTINGS_DB", "0")
# Phase 61 (defect fix): the app under test must see the code DEFAULTS, # Phase 61 (defect fix): the app under test must see the code DEFAULTS,
# not an operator's local (gitignored) ``.env`` — ``Settings`` loads # not an operator's local (gitignored) ``.env`` — ``Settings`` loads
# ``env_file=".env"`` from the repo root, and a machine-specific corpus # ``env_file=".env"`` from the repo root, and a machine-specific corpus
+11
View File
@@ -52,6 +52,17 @@ SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
# (``env["BOR_PDF_PICTORIAL"] = "1"`` in its module app). # (``env["BOR_PDF_PICTORIAL"] = "1"`` in its module app).
os.environ["BOR_PDF_PICTORIAL"] = "0" os.environ["BOR_PDF_PICTORIAL"] = "0"
# Phase 168 (task 02): the DB-over-env overlay
# (app/core/settings_overlay) is pinned OFF for every E2E app — the
# cross-suite poison guard (the phase-140 BOR_PDF_PICTORIAL pin, same
# reasoning): a row saved by one suite (tests/e2e/
# test_admin_settings_panel.py opts in with BOR_SETTINGS_DB=1 in its
# own module app fixture — the test_pictorial_pdf.py pattern) would
# otherwise silently retune every later suite's app subprocess. The
# line rides into every app's env through the house ``env =
# dict(os.environ)`` pattern.
os.environ["BOR_SETTINGS_DB"] = "0"
def _wait_http(url: str, timeout: float = 40.0) -> None: def _wait_http(url: str, timeout: float = 40.0) -> None:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
"""Integration: the admin settings panel's read/write surface (phase 168,
task 04) — ``/api/admin/settings`` GET / PUT ``/{key}`` / DELETE
``/{key}`` / DELETE all, driven through the real app (TestClient keeps
the cookie jar — the house ``test_ui_settings_api`` /
``test_tokens_api`` pattern).
The pins (00_phase, Testing & Quality):
* the auth matrix — anonymous AND a signed-in token USER get 403 on ALL
four routes (the router-wide ``require_admin`` closes the surface on
every method), the admin 200s on all four;
* the shape — ``items`` length == ``len(Settings.model_fields)`` (75),
groups in ``GROUPS`` order (the stable order the E2E pins against),
``totals.count`` / ``overridden`` correct, ``overlay_enabled`` true;
on an empty table a field whose ``BOR_*`` var the test env sets
reports ``source="env"``, an untouched one ``source="default"`` and
none reports ``"db"``;
* write → live — a saved ``relevance_threshold`` is in
``get_settings()`` with NO restart (the cache clear + task-03 hooks
are the whole job), the row is in the table with a non-null
``updated_at``, and the DB value reaches the ANONYMOUS
``/api/config`` endpoint through the same overlay;
* errors — unknown key 404; the two LOCKED A3 keys 422 naming the env
var; a bad shape 422 naming the field with NOTHING stored; a
cross-field pair that only breaks in combination (the second PUT 422s
and the first row is unchanged);
* masking (LOCKED A4) — a stored secret reports ``masked``/``has_value``
only, the plaintext appears NOWHERE in the raw response text, a blank
secret PUT 422s, and a DELETE clears it;
* reset — a DELETE ``/{key}`` restores the env value (idempotent);
DELETE ``""`` clears everything (``{"cleared": 3}``);
* the kill switch (D6) — ``BOR_SETTINGS_DB=0``: ``overlay_enabled``
false, a PUT still stores the row, ``get_settings()`` still reports
the env value — the switch disables the mechanism, not the panel;
* the byte budget — the whole catalogue stays under the phase-167 INFO
bar (64 KB), so it never floods the log;
* fail-soft boot — a malformed row inserted DIRECTLY into the table
(bypassing the API's 422 gate) never bricks: a fresh ``create_app()``
runs with it in the table, the row is skipped with a logged reason,
the rest of the table still applies, and ``/api/health`` is 200.
The module's teardown leaves NO row behind (the same discipline
``test_admin_theme_tab.py`` applies to ``ui_settings`` — a surviving
row would retune every later suite through the overlay).
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api import chat as chat_api
from app.config import Settings, get_settings, invalidate_settings
from app.core import settings_overlay
from app.core.settings_catalog import CATALOG, GROUPS
from app.core.settings_overlay import register_invalidator
from app.main import app as fastapi_app
from app.main import create_app
from tests.conftest import ADMIN_PASSWORD
API = "/api/admin/settings"
@pytest.fixture(autouse=True)
def clean_state(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""A clean ``app_settings`` table + the overlay switch ON + a fresh
overlay/hook slate (the ``test_admin_settings_live`` idiom: reset
the process-wide hook list and re-wire what ``create_app()``
wires, so a save in this module runs the chat runtime reset).
Teardown deletes the rows + invalidates + clears the cache — NO row
may survive this module (a surviving row would retune every later
suite through the overlay).
"""
settings_overlay.reset_state_for_tests()
register_invalidator(chat_api.reset_runtime_caches)
db.execute(text("DELETE FROM app_settings"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
invalidate_settings()
yield
db.execute(text("DELETE FROM app_settings"))
db.commit()
invalidate_settings()
get_settings.cache_clear()
def _admin_client() -> TestClient:
"""A fresh client signed in as the admin (the ``_admin_client``
pattern from ``test_ui_settings_api``)."""
c = TestClient(fastapi_app)
r = c.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return c
def _token_user_client(admin: TestClient, anon: TestClient) -> TestClient:
"""A signed-in token USER (role ``"user"`` — issue the token the way
``test_tokens_api`` does): the only non-admin identity, 403 on this
surface on every method (the phase-79 token matrix contract)."""
r = admin.post("/api/tokens", json={"label": "alice"})
assert r.status_code == 201, r.text
token = r.json()["token"]
assert anon.post("/api/token-auth", json={"token": token}).status_code == 204
return anon
def _item(body: dict, key: str) -> dict:
"""One catalogue item by key (the GET body's ``items`` list)."""
for item in body["items"]:
if item["key"] == key:
return item
raise AssertionError(f"catalogue item missing: {key}")
def test_auth_matrix_all_four_routes(client: TestClient, db: Session) -> None:
"""Anonymous AND token user: 403 ``admin only`` on ALL four routes
(the router-wide dependency closes the surface on every method);
the admin passes on all four."""
admin = _admin_client()
token_user = _token_user_client(admin, client)
for caller in (client, token_user):
r = caller.get(API)
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
r = caller.put(f"{API}/relevance_threshold", json={"value": "0.5"})
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
r = caller.delete(f"{API}/relevance_threshold")
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
r = caller.delete(API)
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
r = admin.get(API)
assert r.status_code == 200
r = admin.put(f"{API}/top_n_docs", json={"value": "3"})
assert r.status_code == 200, r.text
r = admin.delete(f"{API}/top_n_docs")
assert r.status_code == 200, r.text
r = admin.delete(API)
assert r.status_code == 200, r.text
assert r.json() == {"cleared": 0}
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
def test_catalogue_shape_and_sources_on_empty_table(admin_client) -> None:
"""The whole catalogue, grouped and stable: 75 items (exactly
``Settings.model_fields``), groups in ``GROUPS`` order (each group a
contiguous block, the block order = ``GROUPS``), ``totals`` correct,
``overlay_enabled`` true. On an empty table: the env-var-set field
reports ``"env"``, the untouched one ``"default"`` and NOTHING
reports ``"db"``."""
r = admin_client.get(API)
assert r.status_code == 200, r.text
body = r.json()
items = body["items"]
assert len(items) == len(Settings.model_fields)
assert body["totals"] == {
"count": len(Settings.model_fields),
"overridden": 0,
}
assert body["overlay_enabled"] is True
# The stable order: each group a contiguous block, block order GROUPS.
groups_seen: list[str] = []
for item in items:
assert item["group"] in GROUPS
if not groups_seen or groups_seen[-1] != item["group"]:
groups_seen.append(item["group"])
assert groups_seen == [g for g in GROUPS if g in groups_seen]
assert len(groups_seen) == len(set(groups_seen))
# Every item carries the full contract (the panel generates from it).
for item in items:
assert set(item) == {
"key", "env_var", "group", "label", "type", "secret",
"restart_required", "overridable", "source", "value",
"masked", "has_value", "has_override", "db_value", "default",
}
assert item["env_var"] == "BOR_" + item["key"].upper()
assert item["source"] in ("db", "env", "default")
# Empty table: env / default / never db.
sources = {item["key"]: item["source"] for item in items}
assert "db" not in sources.values()
assert sources["input_placeholder"] == "env" # BOR_… set in tests/conftest.py
assert sources["docs_work_dir"] == "default" # no BOR_… in the test env
assert sources["relevance_threshold"] == "env" # pinned 0.30 in conftest
def test_write_is_live_without_restart(admin_client, db: Session) -> None:
"""Write → live (no restart): the saved value is in ``get_settings()``
in the SAME process, the row is in the table with a non-null
``updated_at``, the item reports ``source="db"`` and
``totals.overridden == 1`` — and the DB value reaches the ANONYMOUS
``/api/config`` endpoint through the same overlay (the DB-over-env
rule is deployment-wide, not panel-local)."""
assert get_settings().relevance_threshold == 0.30 # the conftest env value
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
assert r.status_code == 200, r.text
body = r.json()
assert body["key"] == "relevance_threshold"
assert body["source"] == "db"
assert body["has_override"] is True
assert body["db_value"] == "0.5"
assert get_settings().relevance_threshold == 0.5 # NO restart (task 03)
row = db.execute(
text("SELECT value, updated_at FROM app_settings WHERE key = 'relevance_threshold'")
).first()
assert row is not None
assert row[0] == "0.5"
assert row[1] is not None # the server-side now()
r = admin_client.get(API)
assert r.json()["totals"]["overridden"] == 1
# Visible through the public config endpoint (no auth) — the same
# overlay retunes every consumer at once (D2).
anon = TestClient(fastapi_app)
r = anon.get("/api/config")
assert r.status_code == 200
assert r.json()["input_placeholder"] == "Ask me anything…"
r = admin_client.put(f"{API}/input_placeholder", json={"value": "Type your question…"})
assert r.status_code == 200, r.text
r = anon.get("/api/config")
assert r.status_code == 200
assert r.json()["input_placeholder"] == "Type your question…"
def test_put_errors_404_and_422(admin_client, db: Session) -> None:
"""The write-path guards (LOCKED A5, A3, A4): unknown key 404; the
two A3 keys 422 naming the env var; a bad shape 422 naming the FIELD
with NOTHING stored; a cross-field pair that only breaks in
combination — the second PUT 422s and the first row is unchanged."""
r = admin_client.put(f"{API}/bogus_key", json={"value": "1"})
assert r.status_code == 404
assert r.json() == {"detail": "Unknown setting bogus_key"}
r = admin_client.put(f"{API}/database_url", json={"value": "postgresql://x"})
assert r.status_code == 422
assert "BOR_DATABASE_URL is not settable from the database" in r.json()["detail"]
r = admin_client.put(f"{API}/embedding_dim", json={"value": "384"})
assert r.status_code == 422
assert "BOR_EMBEDDING_DIM is not settable from the database" in r.json()["detail"]
# A bad shape: names the field, stores nothing.
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "abc"})
assert r.status_code == 422
assert "relevance_threshold" in r.json()["detail"]
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
# The one list[str] field (K2): a malformed JSON array is a 422
# naming the field (the _coerce ValueError branch), nothing stored.
r = admin_client.put(f"{API}/suggestions", json={"value": "[not json"})
assert r.status_code == 422
assert "suggestions" in r.json()["detail"]
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
# A well-formed JSON array stores fine (the JSON round-trip works).
r = admin_client.put(
f"{API}/suggestions", json={"value": '["How do I deploy gitea?"]'}
)
assert r.status_code == 200, r.text
assert r.json()["db_value"] == '["How do I deploy gitea?"]'
assert db.execute(
text("SELECT value FROM app_settings WHERE key = 'suggestions'")
).scalar() == '["How do I deploy gitea?"]'
# (Clean up the row — the cross-field pin below counts rows.)
assert admin_client.delete(f"{API}/suggestions").status_code == 200
# The cross-field pair (only breaks in combination): store
# relevance_threshold=0.2 (valid alone), then lexical_support_floor=0.9
# — above the DB-overridden threshold: the SECOND PUT 422s and the
# first row is unchanged.
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.2"})
assert r.status_code == 200, r.text
r = admin_client.put(f"{API}/lexical_support_floor", json={"value": "0.9"})
assert r.status_code == 422
assert "lexical_support_floor" in r.json()["detail"]
value = db.execute(
text("SELECT value FROM app_settings WHERE key = 'relevance_threshold'")
).scalar()
assert value == "0.2" # the first row is unchanged
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 1
# The failed write did not disturb the live view.
assert get_settings().relevance_threshold == 0.2
def test_secrets_are_masked_and_write_only(admin_client, db: Session) -> None:
"""LOCKED A4: a stored secret reports ``masked``/``has_value`` only —
``value``/``db_value`` are ``None`` and the plaintext appears NOWHERE
in the raw response text (asserted on ``response.text``, not the
parsed model); a blank secret PUT 422s (a self-lockout — DELETE is
the explicit clear); a DELETE clears it."""
db.execute(
text("INSERT INTO app_settings (key, value) VALUES ('llm_api_key', :v)"),
{"v": "sk-secret-12345"},
)
db.commit()
invalidate_settings()
r = admin_client.get(API)
assert r.status_code == 200
item = _item(r.json(), "llm_api_key")
assert item["masked"] is True
assert item["value"] is None
assert item["db_value"] is None
assert item["has_value"] is True # a non-empty value is in effect
assert item["has_override"] is True
assert item["source"] == "db"
assert item["default"] == "" # masked: the code default is never shown
assert "sk-secret-12345" not in r.text # NOWHERE in the raw body
# The other write-only fields are masked too (set in the test env).
for key in ("admin_password", "session_secret", "database_url"):
other = _item(r.json(), key)
assert other["masked"] is True and other["value"] is None
# A blank secret PUT is a 422 (an empty API key / session secret
# would self-lockout — DELETE is the explicit clear).
r = admin_client.put(f"{API}/llm_api_key", json={"value": " "})
assert r.status_code == 422
assert "BOR_LLM_API_KEY must not be blank" in r.json()["detail"]
assert db.execute(
text("SELECT value FROM app_settings WHERE key = 'llm_api_key'")
).scalar() == "sk-secret-12345" # unchanged
# A DELETE clears the row — the effective value falls back to the
# env value (whatever this deployment's env/.env provides — the
# test only pins that it is NOT the stored secret and that the
# item reports the fallback state).
r = admin_client.delete(f"{API}/llm_api_key")
assert r.status_code == 200
assert r.json()["source"] == "default" # no BOR_LLM_API_KEY in the PROCESS env
assert r.json()["has_override"] is False
assert r.json()["value"] is None
assert r.json()["has_value"] == bool(get_settings().llm_api_key)
assert get_settings().llm_api_key != "sk-secret-12345" # the override is gone
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
def test_reset_to_env_and_clear_all(admin_client, db: Session) -> None:
"""Reset semantics: DELETE ``/{key}`` deletes the row (idempotent — a
double Reset is a success, not a 404) and the env value wins again;
DELETE ``""`` clears every row (``{"cleared": 3}``) and
``totals.overridden`` is back to 0."""
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
assert r.status_code == 200, r.text
assert get_settings().relevance_threshold == 0.5
r = admin_client.delete(f"{API}/relevance_threshold")
assert r.status_code == 200, r.text
assert r.json()["source"] == "env" # BOR_RELEVANCE_THRESHOLD is set (0.30)
assert r.json()["has_override"] is False
assert get_settings().relevance_threshold == 0.30 # the env value wins again
# Idempotent: the double Reset is a success, not a 404.
r = admin_client.delete(f"{API}/relevance_threshold")
assert r.status_code == 200, r.text
# Three rows, then "Clear all overrides".
for key, value in (("relevance_threshold", "0.5"), ("llm_retries", "7"), ("top_n_docs", "3")):
r = admin_client.put(f"{API}/{key}", json={"value": value})
assert r.status_code == 200, r.text
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 3
r = admin_client.delete(API)
assert r.status_code == 200
assert r.json() == {"cleared": 3}
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
assert get_settings().relevance_threshold == 0.30 # env values win again
r = admin_client.get(API)
assert r.json()["totals"]["overridden"] == 0
def test_kill_switch_disables_the_mechanism_not_the_panel(
admin_client, db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""D6: with ``BOR_SETTINGS_DB=0`` the catalogue reports
``overlay_enabled`` false (the panel shows a banner instead of
pretending a save took effect), a PUT STILL stores the row, and
``get_settings()`` still reports the ENV value — the switch disables
the mechanism, not the panel."""
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
assert r.status_code == 200, r.text
assert get_settings().relevance_threshold == 0.5 # the switch was ON
# Deterministic env base for the pin (machine-independent):
monkeypatch.setenv("BOR_LLM_RETRIES", "3")
invalidate_settings()
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
invalidate_settings()
r = admin_client.get(API)
assert r.status_code == 200
assert r.json()["overlay_enabled"] is False
assert r.json()["totals"]["overridden"] == 0 # no row is IN EFFECT
item = _item(r.json(), "relevance_threshold")
assert item["source"] == "env" # the row exists but is not in effect
assert item["has_override"] is True
assert item["db_value"] == "0.5" # the stored string is visible (non-secret)
assert get_settings().relevance_threshold == 0.30 # the env value stands
# The panel stays writable: the PUT stores the row …
r = admin_client.put(f"{API}/llm_retries", json={"value": "7"})
assert r.status_code == 200, r.text
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 2
# … but the env value stays in effect while the switch is off.
assert get_settings().llm_retries == 3 # the env value (pinned 3 above)
def test_catalogue_stays_under_the_byte_budget(admin_client) -> None:
"""The whole catalogue stays under the phase-167 INFO bar (64 KB —
``BOR_LOG_LARGE_RESPONSE_BYTES``), so ``GET /api/admin/settings``
never floods the log (75 items ≈ 20 KB; the audit script re-verifies
the 256 KB budget in task 06)."""
r = admin_client.get(API)
assert r.status_code == 200
assert len(r.content) < 65_536
def test_unknown_delete_is_a_404(admin_client) -> None:
"""A DELETE of an UNKNOWN SETTING is a 404 like the PUT (a typo in
the key is a client bug; the idempotency pin is about a known key
whose row is already gone — the double Reset in
``test_reset_to_env_and_clear_all``)."""
r = admin_client.delete(f"{API}/bogus_key")
assert r.status_code == 404
assert r.json() == {"detail": "Unknown setting bogus_key"}
def test_write_log_lines_carry_never_the_value(
admin_client, caplog: pytest.LogCaptureFixture
) -> None:
"""AGENTS rule 10 / A4: one line per write (``settings action=…`` on
the ``app.api.admin_settings`` logger) carrying the key and the
action — NEVER the value (a secret write must not leak the
plaintext into the log)."""
caplog.set_level(logging.INFO, logger="app.api.admin_settings")
r = admin_client.put(f"{API}/llm_api_key", json={"value": "sk-log-secret-xyz"})
assert r.status_code == 200, r.text
set_lines = [rec.message for rec in caplog.records
if rec.name == "app.api.admin_settings"]
assert "settings action=set key=llm_api_key status=200" in set_lines
assert "sk-log-secret-xyz" not in "\n".join(set_lines)
caplog.clear()
r = admin_client.delete(f"{API}/llm_api_key")
assert r.status_code == 200
assert "settings action=clear key=llm_api_key" in [
rec.message for rec in caplog.records
if rec.name == "app.api.admin_settings"
]
caplog.clear()
admin_client.put(f"{API}/top_n_docs", json={"value": "3"})
r = admin_client.delete(API)
assert r.status_code == 200 and r.json() == {"cleared": 1}
assert "settings action=clear_all count=1" in [
rec.message for rec in caplog.records
if rec.name == "app.api.admin_settings"
]
def test_bad_row_inserted_directly_never_bricks_boot(
db: Session, caplog: pytest.LogCaptureFixture
) -> None:
"""Completion criterion (00_phase, item 5): a malformed row written
straight into ``app_settings`` (bypassing the API's 422 gate — the
operator SQL, the failed migration) must never brick boot. A FRESH
``create_app()`` — the full boot path: fail-loud auth gate, resolver
prime, middleware wiring — runs with the bad row present, the row is
SKIPPED with a logged reason, the rest of the table still applies,
and ``/api/health`` is 200 (A5)."""
# One bad row (the API's 422 gate would refuse it) + one good row:
# the bad one is skipped, the good one stands.
db.execute(text(
"INSERT INTO app_settings (key, value)"
" VALUES ('relevance_threshold', 'not-a-number')"
))
db.execute(text(
"INSERT INTO app_settings (key, value) VALUES ('top_n_docs', '3')"
))
db.commit()
caplog.set_level(logging.ERROR, logger="app.settings")
# The boot path with the bad row in the table — must not raise.
app = create_app()
s = get_settings()
env_only = Settings() # the env value the skipped key falls back to
assert s.relevance_threshold == env_only.relevance_threshold
assert s.top_n_docs == 3 # the rest of the table still applies
assert any(
"settings override ignored" in rec.message
and "relevance_threshold" in rec.message
for rec in caplog.records
)
r = TestClient(app).get("/api/health")
assert r.status_code == 200
def test_catalog_covers_every_settings_field(admin_client) -> None:
"""The catalogue's keys are exactly ``Settings.model_fields`` (the
API-level face of the unit completeness pin — a new setting cannot
be missing from the panel's data)."""
r = admin_client.get(API)
assert r.status_code == 200
assert {item["key"] for item in r.json()["items"]} == set(Settings.model_fields)
assert set(CATALOG) == set(Settings.model_fields)
@@ -0,0 +1,217 @@
"""Phase 168 (task 03): the end-to-end proof of "no restart", in-process.
After ``invalidate_settings()`` (the save-side wrapper), the NEXT
request behaves under the new value — pinned for the three settings
that are NOT covered by the cache clear alone or that prove it end
to end: the retrieval honesty gate (``relevance_threshold`` — the
per-use read, ``app/api/chat.py``'s ``plan_turn`` gate), the shared
``LLMClient`` (rebuilt: model + timeout from the new ``Settings``
snapshot, ``app/api/chat.py::get_llm``), and the chat concurrency
semaphore (``chat_max_concurrent``). Plus the kill-switch counterpart
(``BOR_SETTINGS_DB=0`` with rows present → env values are back) and
the ``scripts/``-consumer surface (``import_extension_set``).
The rows are inserted directly through the ``db`` session (the admin
API is task 04); the save itself is ``invalidate_settings()`` —
exactly what task 04's PUT/DELETE call after the write.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api import chat as chat_api
from app.config import get_settings, invalidate_settings
from app.core import settings_overlay
from app.core.settings_overlay import register_invalidator
from app.models import Document
from app.rag.retriever import RetrievedChunk
@pytest.fixture(autouse=True)
def _clean_state(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""A clean ``app_settings`` table + a fresh overlay/hook slate.
``reset_state_for_tests()`` clears the process-wide hook list (a
prior suite's test may have wiped the hook ``app.main`` registered
at import — re-wire what ``create_app()`` wires: the chat runtime
reset), then the table is emptied and the switch is ON for the
test. Teardown deletes the rows and invalidates (runs the reset
hook — a test-built ``LLMClient``/semaphore snapshot never leaks
into a later suite) + clears the cache; ``monkeypatch`` then
restores ``BOR_SETTINGS_DB`` and the next resolution is the
conftest-pinned env-only view.
"""
settings_overlay.reset_state_for_tests()
register_invalidator(chat_api.reset_runtime_caches)
db.execute(text("DELETE FROM app_settings"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
invalidate_settings()
yield
db.execute(text("DELETE FROM app_settings"))
db.commit()
invalidate_settings()
get_settings.cache_clear()
def _save(db: Session, key: str, value: str) -> None:
"""One upserted override row (the panel's Save, minus the API)."""
db.execute(
text(
"INSERT INTO app_settings (key, value) VALUES (:k, :v)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
),
{"k": key, "v": value},
)
db.commit()
def _doc(title: str) -> Document:
return Document(
id=uuid.uuid4(),
source="Homelab",
path=f"{title.lower().replace(' ', '-')}.md",
full_path="/tmp/doc.md",
title=title,
content=f"{title} content body for the gate pin",
summary=None,
content_hash="0" * 64,
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
def _chunk(doc: Document, score: float, cosine: float) -> RetrievedChunk:
"""A non-FTS candidate: *cosine* is the honesty-gate input."""
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content=doc.content[:32],
score=score,
document=doc,
cosine=cosine,
)
# ---------------------------------------------------------------------------
# the per-use read: the A8 gate behaves under the saved threshold
# ---------------------------------------------------------------------------
def test_relevance_threshold_db_value_bites_in_the_gate(db: Session) -> None:
base = get_settings().relevance_threshold
if base >= 0.95:
pytest.skip("env threshold too close to 1.0 to bracket a DB override")
# A DB value over the base; a cosine BETWEEN them — grounded under
# the base, deflected under the DB value (no FTS: fts_hits = 0).
db_value = min(base + 0.2, 0.9)
cosine = (base + db_value) / 2
chunk = _chunk(_doc("Gate Pin"), score=0.1, cosine=cosine)
assert chat_api.plan_turn([chunk], get_settings()).deflected is False
_save(db, "relevance_threshold", str(db_value))
invalidate_settings()
assert get_settings().relevance_threshold == db_value
# the SAME chunk, the SAME function, the overlay-fed settings —
# the gate now deflects (no process restart in between)
assert chat_api.plan_turn([chunk], get_settings()).deflected is True
# ---------------------------------------------------------------------------
# the LLM client snapshot: Save swaps the shared LLMClient
# ---------------------------------------------------------------------------
def test_llm_client_rebuilt_from_saved_values(db: Session) -> None:
_save(db, "llm_chat_model", "gpt-test")
_save(db, "llm_timeout", "7")
invalidate_settings()
llm = chat_api.get_llm()
# the rebuilt client holds the DB-overridden snapshot…
assert llm.settings.llm_chat_model == "gpt-test"
assert llm.settings.llm_timeout == 7.0
# …and its AsyncOpenAI transport was built from it (base_url +
# the timeout's connect value — the way llm.py's __init__ builds
# it; the openai client enforces a trailing slash on base_url)
assert llm._client.base_url == ( # pyright: ignore[reportPrivateUsage]
llm.settings.llm_base_url.rstrip("/") + "/"
)
assert llm._client._client.timeout.connect == 7.0 # pyright: ignore[reportPrivateUsage]
def test_llm_client_repoints_on_the_next_save(db: Session) -> None:
_save(db, "llm_chat_model", "gpt-first")
invalidate_settings()
assert chat_api.get_llm().settings.llm_chat_model == "gpt-first"
# the NEXT save re-points the singleton (not just the first)
_save(db, "llm_chat_model", "gpt-second")
invalidate_settings()
llm = chat_api.get_llm()
assert llm.settings.llm_chat_model == "gpt-second"
# ---------------------------------------------------------------------------
# the chat concurrency semaphore follows the saved cap
# ---------------------------------------------------------------------------
def test_chat_semaphore_follows_saved_cap(db: Session) -> None:
base_cap = get_settings().chat_max_concurrent
first = 3 if base_cap != 3 else 5
_save(db, "chat_max_concurrent", str(first))
invalidate_settings()
assert get_settings().chat_max_concurrent == first
sem = chat_api._get_chat_semaphore()
assert sem._value == first # pyright: ignore[reportPrivateUsage]
# the next save re-points the semaphore too
second = 5 if first == 3 else 3
_save(db, "chat_max_concurrent", str(second))
invalidate_settings()
rebuilt = chat_api._get_chat_semaphore()
assert rebuilt is not sem
assert rebuilt._value == second # pyright: ignore[reportPrivateUsage]
# ---------------------------------------------------------------------------
# the kill switch: rows present, switch off → the env values are back
# ---------------------------------------------------------------------------
def test_kill_switch_returns_env_values(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
base = get_settings().input_placeholder
_save(db, "input_placeholder", "DB-WINS-PLACEHOLDER")
invalidate_settings()
assert get_settings().input_placeholder == "DB-WINS-PLACEHOLDER"
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
invalidate_settings()
assert get_settings().input_placeholder == base
# ---------------------------------------------------------------------------
# the scripts/ consumer: the importer reads the same overlay
# ---------------------------------------------------------------------------
def test_import_extensions_override_reaches_script_consumer(db: Session) -> None:
base = get_settings().import_extension_set
# a REAL change — guard against a dev .env that already carries
# exactly this set (a no-op would still pass the final assert)
if base == {".md", ".txt", ".csv"}:
saved, expect = "md,txt,csv,ini", {".md", ".txt", ".csv", ".ini"}
else:
saved, expect = "md,txt,csv", {".md", ".txt", ".csv"}
_save(db, "import_extensions", saved)
invalidate_settings()
assert get_settings().import_extension_set == expect
@@ -88,6 +88,7 @@ SHELL_BACKED_PAGES = {
"/history.html": "index.html", # phase 76 task 03 "/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06 "/tokens.html": "index.html", # phase 79 task 06
"/theme.html": "index.html", # phase 91 task 04 "/theme.html": "index.html", # phase 91 task 04
"/settings.html": "index.html", # phase 168 task 05
} }
+283
View File
@@ -0,0 +1,283 @@
"""Integration: migration 0028 (app_settings) schema contract
(phase 168, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0017.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0027 → 0028 step
explicitly so later migrations cannot break them:
* upgrade 0027 → 0028 → the ``app_settings`` table exists with the
full column contract — ``key`` VARCHAR(100) NOT NULL and the
primary key (one row per BARE ``Settings`` field name; the PK is
the uniqueness guard — two rows for one setting would make
"which one wins" undefined), ``value`` TEXT NOT NULL (the env-
native string form; the reset is a DELETE, never an empty value),
``updated_at`` TIMESTAMPTZ NOT NULL with the now() server default
(house style) — while the 0027 schema (``documents.
page_strip_mode``) survives;
* inserted rows round-trip (``relevance_threshold`` / ``"0.55"`` and
``import_extensions`` / ``"md,txt"``);
* a duplicate ``key`` raises ``IntegrityError`` (the PK pin) and a
NULL ``value`` is rejected (NOT NULL);
* downgrade to 0027 → the table is GONE (A13 — reversible), the rest
of the schema (``documents.page_strip_mode``, ``ui_settings``)
survives;
* upgrade back to 0028 → the table is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
return (
db.execute(
text("SELECT 1 FROM information_schema.tables WHERE table_name = :t"),
{"t": table},
).scalar()
is not None
)
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default, character_maximum_length)
for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default, character_maximum_length"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _pk_columns(db: Session, table: str) -> list[str]:
"""The table's PRIMARY KEY columns in ordinal position."""
rows = db.execute(
text(
"SELECT kcu.column_name"
" FROM information_schema.table_constraints tc"
" JOIN information_schema.key_column_usage kcu"
" ON kcu.constraint_name = tc.constraint_name"
" AND kcu.table_name = tc.table_name"
" WHERE tc.table_name = :t"
" AND tc.constraint_type = 'PRIMARY KEY'"
" ORDER BY kcu.ordinal_position"
),
{"t": table},
).fetchall()
return [r[0] for r in rows]
def _clear_rows(db: Session) -> None:
db.execute(text("DELETE FROM app_settings"))
db.commit()
def test_upgrade_to_0028_creates_app_settings(db: Session, alembic: Config) -> None:
"""Upgrade 0027 → 0028: the table exists with the full column
contract (``key`` VARCHAR(100) NOT NULL + the sole PK; ``value``
TEXT NOT NULL; ``updated_at`` TIMESTAMPTZ NOT NULL with the now()
server default), and the table is ABSENT at 0027 while the 0027
``documents`` schema survives the upgrade."""
command.downgrade(alembic, "0027") # start from the pre-0028 state
assert _version(db) == "0027"
assert not _table_exists(db, "app_settings"), (
"app_settings must be absent at 0027"
)
command.upgrade(alembic, "0028")
assert _version(db) == "0028", "alembic_version must be at 0028"
assert _table_exists(db, "app_settings"), "the table must exist at 0028"
key = _column(db, "app_settings", "key")
assert key is not None, "app_settings.key is missing"
assert key[0] == "character varying", "key must be VARCHAR"
assert key[1] == "NO", "key must be NOT NULL (PK)"
assert key[2] is None, "key must have no server default"
assert key[3] == 100, "key must be String(100)"
value = _column(db, "app_settings", "value")
assert value is not None, "app_settings.value is missing"
assert value[0] == "text", "value must be TEXT (the env-native string form)"
assert value[1] == "NO", "value must be NOT NULL (reset is a DELETE, never empty)"
updated = _column(db, "app_settings", "updated_at")
assert updated is not None, "app_settings.updated_at is missing"
assert updated[0] == "timestamp with time zone", "updated_at must be TIMESTAMPTZ"
assert updated[1] == "NO", "updated_at must be NOT NULL"
assert updated[2] is not None and "now" in str(updated[2]), (
"updated_at must carry the now() server default (house style)"
)
assert _pk_columns(db, "app_settings") == ["key"], (
"key must be the sole primary key (the uniqueness guard —"
" two rows for one setting make 'which one wins' undefined)"
)
# The 0027 schema survives the additive upgrade.
strip = _column(db, "documents", "page_strip_mode")
assert strip is not None, "documents.page_strip_mode (0027) must survive"
assert strip[3] == 16, "page_strip_mode must stay String(16)"
def test_inserted_rows_round_trip(db: Session, alembic: Config) -> None:
"""At 0028, the env-native string forms round-trip verbatim
(``relevance_threshold`` / ``"0.55"``, ``import_extensions`` /
``"md,txt"``) and ``updated_at`` is stamped by the server default."""
command.upgrade(alembic, "head")
try:
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('relevance_threshold', '0.55')"
)
)
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('import_extensions', 'md,txt')"
)
)
db.commit()
rows = db.execute(
text(
"SELECT key, value, updated_at"
" FROM app_settings ORDER BY key"
)
).fetchall()
assert len(rows) == 2, "both rows must be stored"
assert rows[0][0] == "import_extensions", "key must round-trip verbatim"
assert rows[0][1] == "md,txt", "the CSV string form must round-trip"
assert rows[0][2] is not None, "updated_at must be stamped (server default)"
assert rows[1][0] == "relevance_threshold"
assert rows[1][1] == "0.55", "the numeric string form must round-trip"
finally:
_clear_rows(db)
def test_duplicate_key_is_rejected(db: Session, alembic: Config) -> None:
"""The PK is the uniqueness guard: two rows for one setting would
make 'which one wins' undefined, so the second insert must raise
``IntegrityError``."""
command.upgrade(alembic, "head")
try:
db.execute(
text("INSERT INTO app_settings (key, value) VALUES ('llm_retries', '3')")
)
db.commit()
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('llm_retries', '5')"
)
)
db.rollback() # the IntegrityError aborts the open transaction
finally:
_clear_rows(db)
def test_null_value_is_rejected(db: Session, alembic: Config) -> None:
"""``value`` is NOT NULL: 'clear the override' is a DELETE of the
row, never a NULL/empty value, so the resolver never has to
distinguish 'no override' from 'override to empty'."""
command.upgrade(alembic, "head")
try:
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('chat_max_concurrent', NULL)"
)
)
db.rollback() # the IntegrityError aborts the open transaction
stored = db.execute(
text("SELECT count(*) FROM app_settings")
).scalar()
assert stored == 0, "the rejected row must not be stored"
finally:
_clear_rows(db)
def test_downgrade_to_0027_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade 0028 → 0027: the table is gone (A13 — fully
reversible) while the rest of the schema survives (the 0027
``documents.page_strip_mode`` column, ``ui_settings``,
``api_tokens``). The values are re-enterable from the panel —
they are an override of the env, not a source of truth."""
command.downgrade(alembic, "0027")
assert _version(db) == "0027"
assert not _table_exists(db, "app_settings"), "app_settings must be dropped"
strip = _column(db, "documents", "page_strip_mode")
assert strip is not None and strip[3] == 16, (
"documents.page_strip_mode (0027) must survive the downgrade"
)
semantic = _column(db, "ui_settings", "ok_bg")
assert semantic is not None and semantic[3] == 7, (
"ui_settings.ok_bg must survive the downgrade"
)
token_col = _column(db, "api_tokens", "token_hash")
assert token_col is not None and token_col[0] == "character varying", (
"api_tokens.token_hash must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0027, then upgrade back to 0028: the table is back
with the column contract and PK intact (A13 round-trip)."""
command.downgrade(alembic, "0027")
command.upgrade(alembic, "0028")
assert _version(db) == "0028", "round-trip upgrade must land at 0028"
assert _table_exists(db, "app_settings"), "the table must be back"
key = _column(db, "app_settings", "key")
assert key is not None, "app_settings.key must be back"
assert key[0] == "character varying", "key must be VARCHAR after the round-trip"
assert key[1] == "NO", "key must be NOT NULL after the round-trip"
assert key[3] == 100, "key must be String(100) after the round-trip"
value = _column(db, "app_settings", "value")
assert value is not None, "app_settings.value must be back"
assert value[0] == "text", "value must be TEXT after the round-trip"
assert value[1] == "NO", "value must be NOT NULL after the round-trip"
assert _pk_columns(db, "app_settings") == ["key"]
+174
View File
@@ -0,0 +1,174 @@
"""Unit: the admin settings router's pure helpers (phase 168, task 04)
— the env-native stringification (``_display``), the control-kind
derivation (``_field_type``), the ``_item`` builder's masked and
default branches, and the LOCKED A1 static pins: the module never
opens a file (``.env`` is never written), never shells out and never
``eval``s a value.
The DB-driven behavior (the four routes, the candidate-set validation,
the auth matrix) is pinned in
``tests/integration/test_admin_settings_api.py``.
"""
from __future__ import annotations
import pathlib
import re
import pytest
from app.api.admin_settings import _display, _field_type, _item
from app.config import Settings
from app.core.settings_overlay import LIST_FIELDS
def test_display_bools_are_the_env_spelling() -> None:
"""bool → ``"true"``/``"false"`` — checked BEFORE int (``isinstance(True, int)``
is ``True`` — a bool field must never stringify through ``str()``'s
``"True"``)."""
assert _display("stream_thinking", True) == "true"
assert _display("stream_thinking", False) == "false"
# An int value for an int field is the plain str (no bool confusion).
assert _display("llm_retries", 3) == "3"
def test_display_list_field_is_json() -> None:
"""The one ``list[str]`` field (``suggestions``) stringifies as the
JSON array the env spelling would carry (K2: stored in the same
JSON form, pydantic parses it exactly like the env var)."""
assert set(LIST_FIELDS) == {"suggestions"}
assert _display("suggestions", ["a", "b"]) == '["a", "b"]'
assert _display("suggestions", []) == "[]"
def test_display_scalars_are_plain_strings() -> None:
"""Everything else is ``str()`` — floats, ints, strings, in the
env-native form (what ``app_settings`` stores, D1)."""
assert _display("relevance_threshold", 0.55) == "0.55"
assert _display("llm_timeout", 7.0) == "7.0"
assert _display("app_name", "Brain of Reese") == "Brain of Reese"
assert _display("import_extensions", "md,txt") == "md,txt"
def test_field_type_covers_all_kinds() -> None:
"""The panel control kind per annotation: ``bool`` before ``int``
(a bool annotation is never misread as int), the one list field is
``json``, the rest fall through to their annotation."""
assert _field_type("stream_thinking") == "bool"
assert _field_type("llm_retries") == "int"
assert _field_type("relevance_threshold") == "float"
assert _field_type("app_name") == "str"
assert _field_type("suggestions") == "json"
# Every field resolves to one of the five wire kinds.
kinds = {"int", "float", "bool", "str", "json"}
for key in Settings.model_fields:
assert _field_type(key) in kinds
def test_item_masks_secret_fields() -> None:
"""LOCKED A4: a secret field's item is write-only on the wire —
``masked=True``, ``value=None``, ``db_value=None``,
``default=""`` (the code default is never shown for a secret),
``has_value`` = is a non-empty value in effect at all (the test
env sets a non-empty admin password / session secret)."""
effective = Settings()
assert effective.admin_password # tests/conftest.py pins it non-empty
item = _item("admin_password", {}, effective)
assert item.masked is True
assert item.value is None
assert item.db_value is None
assert item.default == ""
assert item.has_value is True
assert item.secret is True
assert item.key == "admin_password"
assert item.env_var == "BOR_ADMIN_PASSWORD"
# A stored row for a secret field never exposes the text either.
# (The effective value is forced via an init kwarg — the operator's
# local `.env` may legitimately carry a key, so the test does not
# assume a specific effective value.)
item = _item("llm_api_key", {"llm_api_key": "sk-x"}, Settings(llm_api_key="sk-x"))
assert item.masked is True
assert item.value is None
assert item.db_value is None
assert item.has_value is True
assert item.has_override is True
item = _item("llm_api_key", {}, Settings(llm_api_key=""))
assert item.masked is True
assert item.value is None
assert item.db_value is None
assert item.has_value is False # no value in effect at all
def test_item_reports_env_default_and_db_sources(
monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``source`` badge: ``db`` for an in-effect overridable row,
``env`` when the ``BOR_*`` var is in the process environment,
``default`` otherwise — and a non-overridable row is NEVER an
override (LOCKED A3: the overlay drops it, so a hand-inserted
``database_url`` row is not in effect)."""
import os
effective = Settings()
# "env": tests/conftest.py sets BOR_RELEVANCE_THRESHOLD.
assert os.environ.get("BOR_RELEVANCE_THRESHOLD") is not None
item = _item("relevance_threshold", {}, effective)
assert item.source == "env"
# The value is the EFFECTIVE float stringified ("0.30" → 0.3 → "0.3").
assert item.value == str(float(os.environ["BOR_RELEVANCE_THRESHOLD"]))
assert item.has_override is False
# "default": no BOR_DOCS_WORK_DIR in the test environment.
assert os.environ.get("BOR_DOCS_WORK_DIR") is None
item = _item("docs_work_dir", {}, effective)
assert item.source == "default"
assert item.value == effective.docs_work_dir
# "db": the row is in effect only while the overlay switch is ON
# (the conftest pin is 0 — flip it for the pin, it restores after).
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
item = _item("relevance_threshold", {"relevance_threshold": "0.9"}, effective)
assert item.source == "db"
assert item.has_override is True
# Non-overridable: a stored row is not an override (LOCKED A3) —
# even with the switch on.
item = _item("database_url", {"database_url": "postgresql://x"}, effective)
assert item.has_override is False
assert item.masked is True
# The code default stringifies (non-secret fields).
item = _item("llm_retries", {}, effective)
assert item.default == "3"
assert item.value == "3"
assert item.db_value is None
def test_item_db_value_is_the_stored_string() -> None:
"""A stored non-secret override exposes ``db_value`` (the stored
string, D1) and ``has_override`` — even while the overlay switch
is OFF (the row is stored but not in effect: the badge is
env/default, the panel's ``overlay_enabled`` flag explains the
rest)."""
effective = Settings()
item = _item("llm_retries", {"llm_retries": "7"}, effective)
assert item.has_override is True
assert item.db_value == "7"
assert item.source == "default" # switch off (the conftest pin): not in effect
def test_module_never_writes_files_or_shells_out() -> None:
"""LOCKED A1 + the module's third invariant (the unit pin): the
router never opens a file (``.env`` is never written by any code
path), never shells out and never ``eval``s a value. (The docstring
legitimately mentions ``.env`` — the pins are on the CALLS, the
``test_settings_overlay`` house style.)"""
source = (
pathlib.Path(__file__).resolve().parents[2]
/ "app"
/ "api"
/ "admin_settings.py"
).read_text()
assert re.search(r"\bopen\s*\(", source) is None, (
"app/api/admin_settings.py must never open() a file (LOCKED A1: "
".env is never written)"
)
assert "subprocess" not in source
assert "os.system" not in source
assert re.search(r"\beval\s*\(", source) is None
assert "import shutil" not in source and "shutil." not in source
+1
View File
@@ -218,6 +218,7 @@ def test_html_pages_include_history() -> None:
"/history.html", "/history.html",
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route) "/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route) "/theme.html", # phase 91 task 04: the admin theme page (shell route)
"/settings.html", # phase 168 task 05: the admin settings page (shell route)
"/shared.html", # phase 51: the shared page's static path "/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06) "/doc-edit.html", # phase 59: the doc edit screen (task 06)
): ):
+13 -1
View File
@@ -128,8 +128,11 @@ def test_view_map_covers_the_shell_paths() -> None:
assert '"/theme.html": "theme"' in view_body, ( assert '"/theme.html": "theme"' in view_body, (
"phase 91 task 04 folds the Theme view into the shell" "phase 91 task 04 folds the Theme view into the shell"
) )
assert '"/settings.html": "settings"' in view_body, (
"phase 168 task 05 folds the Settings view into the shell"
)
# The view names are the #view-<name> section slugs in index.html. # The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history", "tokens", "theme"): for name in ("chat", "tuning", "history", "tokens", "theme", "settings"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section" assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
@@ -226,6 +229,10 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
assert 'theme: () => import("./theme.js")' in mods_body, ( assert 'theme: () => import("./theme.js")' in mods_body, (
"the Theme view module is lazy-imported on first show (phase 91)" "the Theme view module is lazy-imported on first show (phase 91)"
) )
assert 'settings: () => import("./settings.js")' in mods_body, (
"the Settings view module is lazy-imported on first show "
"(phase 168, a static specifier the bundle inlines)"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module" assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported" assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
@@ -288,6 +295,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
"Set the palette and branding — the theme is baked into every served page, " "Set the palette and branding — the theme is baked into every served page, "
"live on the first paint." "live on the first paint."
) in js ) in js
assert 'settings: "Settings · Brain of Reese"' in js
assert (
"Edit any application setting — a saved value wins over the environment, "
"live on save; reset hands control back."
) in js
# The brand composition (phase 39's window.BOR_BRAND, read at # The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp). # write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js assert 'window.BOR_BRAND || "Brain of Reese"' in js
+21
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.main as main_mod import app.main as main_mod
from app.config import get_settings
from app.core import settings_overlay
def test_create_app_warns_and_serves_api_only_without_static_dir( def test_create_app_warns_and_serves_api_only_without_static_dir(
@@ -30,3 +32,22 @@ def test_create_app_wires_caching_middleware() -> None:
# reach for ``__name__`` the same way starlette's own __repr__ does. # reach for ``__name__`` the same way starlette's own __repr__ does.
middleware_names = [getattr(mw.cls, "__name__", "") for mw in app2.user_middleware] middleware_names = [getattr(mw.cls, "__name__", "") for mw in app2.user_middleware]
assert "CachingMiddleware" in middleware_names assert "CachingMiddleware" in middleware_names
def test_create_app_boots_with_the_switch_on(monkeypatch) -> None:
"""Phase 168 (task 02/03): the boot prime with the kill switch ON
— the ``settings overrides loaded count=…`` branch (the OFF branch
is exercised by every other suite's app import; the overlay's
engine is fail-soft, so a DB-down unit environment still boots).
``create_app`` also wires the chat runtime reset hook here (task
03) — the ``register_invalidator`` pin lives in
``test_settings_live_apply.py``."""
settings_overlay.reset_state_for_tests()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
app2 = main_mod.create_app()
assert app2 is not None
# restore: the switch reverts via monkeypatch; the cache clear
# ensures a boot-time row set never leaks into later suites'
# settings, and the hook slate is fresh for them too.
settings_overlay.reset_state_for_tests()
get_settings.cache_clear()
+100
View File
@@ -0,0 +1,100 @@
"""Phase 168 (task 02): the settings catalogue is complete and consistent.
The Settings panel (task 05) is GENERATED from
``app/core/settings_catalog.py`` — never hand-written — so these pins
are load-bearing: ``set(CATALOG) == set(Settings.model_fields)`` fails
the build the moment a new ``Settings`` field lands without its
metadata (it can never silently disappear from the panel), the group
table is closed both directions, and the capability sets are exactly
the LOCKED decisions (A3/A4).
"""
from __future__ import annotations
from app.config import Settings
from app.core.settings_catalog import (
CATALOG,
GROUPS,
NON_OVERRIDABLE,
RESTART_REQUIRED,
SECRETS,
SettingMeta,
env_var,
)
def test_catalog_covers_exactly_model_fields() -> None:
"""The completeness pin (both directions, the diff in the message)."""
catalog_keys = set(CATALOG)
field_names = set(Settings.model_fields)
missing = sorted(field_names - catalog_keys)
extra = sorted(catalog_keys - field_names)
assert not missing, (
f"Settings fields missing from the catalogue (invisible in the "
f"panel): {missing}"
)
assert not extra, (
f"Catalogue entries with no Settings field (stale): {extra}"
)
assert set(CATALOG) == set(Settings.model_fields)
def test_groups_closed_both_directions() -> None:
catalog_groups = {meta.group for meta in CATALOG.values()}
only_in_groups = sorted(set(GROUPS) - catalog_groups)
only_in_catalog = sorted(catalog_groups - set(GROUPS))
assert not only_in_groups, f"GROUPS entries with no catalogue member: {only_in_groups}"
assert not only_in_catalog, f"Catalogue groups missing from GROUPS: {only_in_catalog}"
def test_every_meta_carries_its_own_key() -> None:
for key, meta in CATALOG.items():
assert isinstance(meta, SettingMeta)
assert meta.key == key
def test_secrets_are_exactly_the_masked_set() -> None:
# LOCKED A4: write-only, masked — the 3 named secrets +
# database_url (read-only, but its URL carries credentials).
assert set(SECRETS) == {
"llm_api_key",
"admin_password",
"session_secret",
"database_url",
}
def test_non_overridable_is_exactly_locked_a3() -> None:
# LOCKED A3: database_url builds the engine that would read the
# override (chicken-and-egg brick); embedding_dim is fixed in the
# chunks table by A6 (a dim override would corrupt retrieval).
assert set(NON_OVERRIDABLE) == {"database_url", "embedding_dim"}
def test_restart_required_is_the_boot_wired_set() -> None:
# LOCKED A4: logging, the gzip middleware (compress_min_bytes), the
# static mount (static_dir), create_engine (db_pool_*), and
# SessionMiddleware (session_*).
assert set(RESTART_REQUIRED) == {
"log_level",
"compress_min_bytes",
"static_dir",
"db_pool_size",
"db_pool_max_overflow",
"db_pool_recycle",
"session_secret",
"session_max_age",
"session_cookie",
}
def test_env_var_is_bare_key_upper_prefixed() -> None:
assert env_var("relevance_threshold") == "BOR_RELEVANCE_THRESHOLD"
for key in CATALOG:
assert env_var(key).startswith("BOR_")
def test_labels_nonempty_and_unique() -> None:
labels = [meta.label for meta in CATALOG.values()]
assert all(label.strip() for label in labels), "empty label in the catalogue"
duplicates = sorted({label for label in labels if labels.count(label) > 1})
assert not duplicates, f"duplicate labels: {duplicates}"
+608
View File
@@ -0,0 +1,608 @@
"""Unit: the phase-168 Settings view wiring (task 05) — the static
source-level pins for the shell's eighth view.
The browser behavior is E2E-gated (the phase-168 story suite —
``test_admin_settings_panel.py``, task 06); here we pin the
source-level invariants the "views of one document" architecture
depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_thinking_scroll.py`` /
``test_frontend_router.py`` read the sources and assert on their
mechanisms).
Pinned (task 05 spec):
* ``index.html`` carries the ``#view-settings`` section (ship-hidden
+ inert + focusable — the WCAG pair, AGENTS.md rule 5) with the
``#settings-gate`` (ship-hidden, its Sign in returning to the
Settings view), the ship-hidden ``#settings-content`` holding the
``#settings-disabled`` kill-switch banner (D6), the ONE
``role="status"`` region, the ``role="alert"`` error line, the
client-side search input, the ``.table-wrap`` catalogue with the
visually-hidden caption + the four-column thead, and the action
row (``#settings-save`` — ships disabled — ``#settings-clear-all``
+ the aria-hidden ``#settings-dirty`` hint); the ``#nav-settings``
link ships hidden (admin-only).
* ``router.js`` maps ``/settings.html`` in ALL THREE tables
(VIEW / VIEW_PATH / VIEW_MODULES — a static specifier so the
Containerfile's esbuild stage inlines it into the router bundle)
+ the per-view title/description (the router is the SINGLE writer
of both).
* ``app/main.py``'s shell tuple and ``app/core/caching.py``'s
``HTML_PAGES`` both list ``/settings.html`` (the no-cache +
``?v=`` contract, A19).
* ``header.js`` reveals ``#nav-settings`` keyed off ``admin`` (NOT
``signedIn`` — a token user never sees it), the same two-line
null-safe pattern as the other admin-only links.
* ``settings.js`` builds the DOM with ``createElement`` /
``textContent`` — NO ``innerHTML`` (template-literal or not) and
NO ``http(s)://`` reference (rule 6 — everything local; the
no-HTML-strings house rule).
* ``styles.css`` declares ``.settings-table`` + the three
``.settings-src.is-*`` variants + the dirty-row marker and adds
NO new ``:root`` variable (the phase-156 B3 rule — the badges
are solid existing tokens).
* the FOUR pages that carry the Theme link (index / document /
login / shared) now carry the Settings link too (the phase-34
one-bar contract — test_nav_consistency pins the parity).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
REPO = Path(__file__).resolve().parents[2]
def _read(path: Path) -> str:
assert path.is_file(), f"missing {path}"
return path.read_text(encoding="utf-8")
def _html() -> str:
return _read(FRONTEND / "index.html")
# ---------- the shell markup: the #view-settings section ----------
def test_settings_view_section_ships_hidden_inert_focusable() -> None:
"""The #view-settings section lives in the shell's single main,
ships hidden AND inert (the WCAG a11y pair — a hidden view must
not receive focus or keyboard traversal), and is focusable
(tabindex="-1" — the router focuses it on user-initiated
switches)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
assert view != -1, "the #view-settings section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
assert 'aria-label="Settings"' in tag, "the view is labelled"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
# The 72rem .container — no new width token (PLAN §7.1).
body = html[view:main_end]
assert 'class="container settings-shell"' in body, (
"the view rides the standard 72rem .container (no new width)"
)
def test_settings_page_head_states_the_rule_in_the_owner_terms() -> None:
"""The page-head: <h1>Settings</h1> + a .page-sub that states
the rule in the owner's terms — the value is stored in the
database and wins over the environment variables; clearing hands
control back to the environment (and the .env file is never
written — LOCKED A1)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
assert "<h1>Settings</h1>" in body
sub = re.search(r'<p class="page-sub">([\s\S]*?)</p>', body)
assert sub, "the .page-sub must exist"
text = " ".join(sub.group(1).split())
assert "stored in the database" in text, (
"the sub states: the value is stored in the database"
)
assert "win over" in text, "the sub states: it wins over the env vars"
assert "hands control back to the environment" in text, (
"the sub states: clearing hands control back to the environment"
)
def test_settings_gate_is_the_tokens_gate_shape_ships_hidden() -> None:
"""The anonymous sign-in gate: the EXACT #tokens-gate shape —
the .sources-gate visual language, ship-hidden, its heading
naming what is admin-only, and its Sign in returning to the
Settings view (?next=/settings.html — the no-JS fallback)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
gate = re.search(r'<section[^>]*id="settings-gate"[^>]*>', body)
assert gate, "the #settings-gate must exist"
assert "hidden" in gate.group(0), "#settings-gate must ship hidden"
assert 'class="sources-gate"' in gate.group(0), (
"the gate reuses the .sources-gate visual language"
)
assert 'aria-labelledby="settings-gate-title"' in gate.group(0)
assert "<h2 id=\"settings-gate-title\">Sign in to change settings</h2>" in body
assert 'href="/login.html?next=/settings.html"' in body, (
"the gate's Sign in returns to the Settings view (no-JS fallback)"
)
def test_settings_content_scaffold_and_feedback_regions() -> None:
"""The ship-hidden #settings-content holds, in order: the
#settings-disabled kill-switch banner (role=alert, ship-hidden —
D6: the mechanism is off, not the panel), the ONE status region
(role=status, aria-live=polite — the #tokens-status precedent),
the role=alert error line (ship-hidden — the #theme-error
precedent), the search row (label + type=search input), the
.table-wrap catalogue (the visually-hidden caption + the
four-column thead with the visually-hidden Actions header), and
the action row (Save — ships DISABLED — Clear all + the
aria-hidden dirty hint)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
content = re.search(r'<div[^>]*id="settings-content"[^>]*>', body)
assert content and "hidden" in content.group(0), (
"#settings-content must ship hidden (anonymous-safe)"
)
banner = re.search(
r'<div[^>]*class="kb-banner is-error"[^>]*id="settings-disabled"[^>]*>'
r"|<div[^>]*id=\"settings-disabled\"[^>]*>",
body,
)
assert banner, "the #settings-disabled banner must exist"
btag_match = re.search(r'<div[^>]*id="settings-disabled"[^>]*>', body)
assert btag_match, "the #settings-disabled banner tag must match"
btag = btag_match.group(0)
assert 'role="alert"' in btag and "hidden" in btag, (
"the banner is a ship-hidden role=alert"
)
status = re.search(
r'<span[^>]*id="settings-status"[^>]*>', body
)
assert status, "the #settings-status region must exist"
stag = status.group(0)
assert 'role="status"' in stag and 'aria-live="polite"' in stag, (
"the ONE status region (the #tokens-status precedent)"
)
error = re.search(r'<p[^>]*id="settings-error"[^>]*>', body)
assert error, "the #settings-error line must exist"
etag = error.group(0)
assert 'role="alert"' in etag and "hidden" in etag, (
"the error line is a ship-hidden role=alert"
)
# The search row: the label + the type=search input (client-side,
# the phase-101 D4 contract — no fetch).
assert '<label for="settings-filter">Search settings</label>' in body
filter_input = re.search(
r'<input[^>]*id="settings-filter"[^>]*>', body, re.S
)
assert filter_input, "the #settings-filter input must exist"
assert 'type="search"' in filter_input.group(0), (
"the filter is a type=search input"
)
# The scroll card: the .table-wrap + the visually-hidden caption
# + the four-column thead (the Actions header visually-hidden —
# the row buttons carry their own aria-labels).
assert 'class="table-wrap"' in body, (
"the catalogue rides the .table-wrap card (the phase-156 "
"bounded scroll + sticky header contract)"
)
table = re.search(r'<table[^>]*class="settings-table"[^>]*>', body)
assert table, "the .settings-table must exist"
thead = body[body.find('class="settings-table"') : body.find("</thead>")]
assert '<caption class="visually-hidden">' in thead, (
"the caption is visually-hidden (the phase-86 table pattern)"
)
assert thead.count('<th scope="col">') == 4, (
"the table is four columns (Setting | Value | Source | Actions)"
)
assert '<th scope="col">Setting</th>' in thead
assert '<th scope="col">Value</th>' in thead
assert '<th scope="col">Source</th>' in thead
assert (
'<th scope="col"><span class="visually-hidden">Actions</span></th>'
in thead
), "the Actions header is visually-hidden (the row buttons are labelled)"
# The action row: Save (type=button, ships DISABLED — no dirty
# rows yet), Clear all (type=button), the aria-hidden dirty hint.
save = re.search(r'<button[^>]*id="settings-save"[^>]*>', body)
assert save, "the #settings-save button must exist"
stag = save.group(0)
assert 'type="button"' in stag and "disabled" in stag, (
"Save is a type=button that ships disabled (no dirty rows)"
)
clear_all = re.search(r'<button[^>]*id="settings-clear-all"[^>]*>', body)
assert clear_all, "the #settings-clear-all button must exist"
assert 'type="button"' in clear_all.group(0)
assert "Clear all overrides" in body
hint = re.search(r'<span[^>]*id="settings-dirty"[^>]*>', body)
assert hint, "the #settings-dirty hint must exist"
assert 'aria-hidden="true"' in hint.group(0), (
"the hint is aria-hidden (the rows + the status carry the info)"
)
def test_settings_nav_link_ships_hidden_in_the_shell() -> None:
"""The #nav-settings link (the shell's eighth view) ships hidden
(admin-only) and UNstamped — the router is the single writer of
the active state, and a token user (role "user") must never see
the link (header.js reveals it for admin only)."""
html = _html()
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', html)
assert match, "the shell must carry the #nav-settings nav link"
link = match.group(0)
assert 'href="/settings.html"' in link, "the Settings link's href"
assert "hidden" in link, "#nav-settings ships hidden (admin-only)"
assert "is-active" not in link, "no static active stamp on the Settings link"
def test_nav_settings_sits_after_nav_theme() -> None:
"""The one-bar contract: the Settings link is the LAST admin link
(after #nav-theme) — the navbar order is part of the
test_nav_consistency inventory, so the position is pinned."""
html = _html()
theme_i = html.find('id="nav-theme"')
settings_i = html.find('id="nav-settings"')
sign_in_i = html.find('id="sign-in-link-mobile"')
assert 0 <= theme_i < settings_i < sign_in_i, (
"the Settings link sits after Theme and before the auth links"
)
# ---------- router.js: /settings.html in ALL THREE tables ----------
def test_router_js_maps_settings_in_all_three_tables() -> None:
"""router.js: VIEW maps /settings.html → settings, VIEW_PATH maps
settings → /settings.html, and VIEW_MODULES lazy-imports
./settings.js with a STATIC specifier (so the Containerfile's
esbuild --bundle of router.js inlines it — settings.js needs no
new esbuild line because no page references it directly;
tests/integration/test_containerfile_assets.py proves the
coverage). The router is the SINGLE writer of the per-view
title/meta — settings has both entries."""
js = _read(ASSETS / "router.js")
view_start = js.find("const VIEW = {")
view_body = js[view_start : js.find("\n}", view_start)]
assert '"/settings.html": "settings"' in view_body, (
"phase 168 task 05 folds the Settings view into the shell"
)
path_start = js.find("const VIEW_PATH = {")
path_body = js[path_start : js.find("\n}", path_start)]
assert 'settings: "/settings.html"' in path_body, (
"VIEW_PATH must map settings → /settings.html"
)
mods_start = js.find("const VIEW_MODULES = {")
mods_body = js[mods_start : js.find("\n}", mods_start)]
assert 'settings: () => import("./settings.js")' in mods_body, (
"the Settings view module is lazy-imported on first show "
"(a static specifier — the bundle inlines it)"
)
# The per-view head values (the router's single-writer contract).
assert 'settings: "Settings · Brain of Reese"' in js, (
"the Settings view's title (carried through brandName)"
)
assert (
"Edit any application setting — a saved value wins over the environment, "
"live on save; reset hands control back."
) in js, "the Settings view's meta description"
# ---------- the server-side registration (shell route + caching) ----------
def test_shell_route_and_html_pages_carry_settings() -> None:
"""app/main.py's _shell_routes tuple and caching.HTML_PAGES both
list /settings.html — without the tuple the deep link 404s
(the static mount has no settings.html file), without the
HTML_PAGES entry it would serve unversioned asset refs, which
the immutable-for-a-year asset caching would pin to stale CSS
after a deploy (the phase-35 lesson, the A19 contract)."""
main_py = _read(REPO / "app" / "main.py")
app_start = main_py.find("def create_app")
mount_i = main_py.find('app.mount("/", StaticFiles')
assert 0 <= app_start < mount_i, "create_app + the static mount must exist"
routes_block = main_py[app_start:mount_i]
assert '"/settings.html"' in routes_block, (
"main.py's shell tuple must list /settings.html (deep link)"
)
caching_py = _read(REPO / "app" / "core" / "caching.py")
pages_start = caching_py.find("HTML_PAGES: tuple")
pages_end = caching_py.find("\n)\n", pages_start)
pages_block = caching_py[pages_start:pages_end]
assert '"/settings.html"' in pages_block, (
"caching.HTML_PAGES must list /settings.html (no-cache + ?v=)"
)
# The shell route comes AFTER the API router registration and
# BEFORE the static mount (routes-first — the docstring contract).
include_i = main_py.find("app.include_router(admin_settings_router")
assert 0 <= app_start < include_i < mount_i, (
"the shell route sits between the API routers and the static mount"
)
# ---------- header.js: the reveal is admin-keyed, null-safe ----------
def test_header_js_reveals_settings_link_for_admin_only() -> None:
"""header.js reveals #nav-settings for role admin — the same
ship-hidden / reveal-for-admin contract as the other admin-only
links: the two-line reveal (`hidden = !admin`) sits in
initSharedHeader, null-safe (a page without the link is a
no-op), and the gate is the `admin` flag (role === "admin") —
a token user (role "user") never sees the link. The lookup +
reveal sit AFTER the whoami resolution (the admin flag exists
only once fetchWhoami has settled)."""
header_js = _read(ASSETS / "header.js")
fn = header_js.find("export async function initSharedHeader")
assert fn != -1, "initSharedHeader must exist"
body = header_js[fn:]
lookup = body.find('document.querySelector("#nav-settings")')
assert lookup != -1, "header.js must look up #nav-settings"
reveal = body.find("navSettings.hidden = !admin")
assert 0 <= lookup < reveal, (
"the reveal must be the two-line pattern: null-safe lookup, "
'then hidden = !admin (the admin flag — role === "admin")'
)
whoami = body.find("const whoami = await fetchWhoami()")
admin_flag = body.find('const admin = whoami.role === "admin"')
assert 0 <= whoami < admin_flag < lookup, (
"the reveal keys off the resolved admin flag"
)
# The gate is `admin`, NOT `signedIn` (a token user never sees
# it — the phase-79 contract).
reveal_line = body[lookup:reveal + 60]
assert "signedIn" not in reveal_line, (
"the reveal must key off admin (a token user never sees the link)"
)
# ---------- settings.js: DOM APIs only, no CDN ----------
def test_settings_js_builds_dom_with_element_apis_only() -> None:
"""settings.js follows the phase-76 view-module contract:
`export async function mount(root)` is the entry, the whoami
gate (fetchIsAdmin) runs in mount and the anonymous branch shows
the gate + hides the content + RETURNS with NO
/api/admin/settings request (the router 403s anonymous), and the
bor:view-refresh listener is armed ONLY in the ADMIN branch
(after the gate), gated on the started flag. The DOM is built
with createElement + textContent — NO innerHTML (template
literal or not: the XSS-safe-by-construction house rule) and NO
http(s):// reference (rule 6 — everything is local; the fetch
paths are same-origin)."""
js = _read(ASSETS / "settings.js")
assert "export async function mount(root)" in js, (
"mount(root) must be the module's entry (the phase-76 fold)"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert 0 <= mount_i < gate_i, "the whoami gate must run in mount"
branch = js[gate_i:js.find("return;", gate_i)]
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything"
)
assert "gateEl.hidden = false" in branch
assert "contentEl.hidden = true" in branch
# The re-show refresh: armed in the ADMIN branch only (after the
# gate), gated on the first load (started).
listener = js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate_i < listener, (
"the refresh listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\) void loadSettings\(\)", js), (
"the listener is gated on the first load (started)"
)
# No HTML-string DOM building (the house rule) — innerHTML is
# absent from the file entirely (a template-literal innerHTML
# would also match this check).
assert "innerHTML" not in js, (
"the DOM is built with createElement + textContent — no "
"innerHTML anywhere (XSS-safe by construction)"
)
# No CDN (rule 6): no http(s):// reference in the module — the
# fetch paths are same-origin (/api/admin/settings…).
assert not re.search(r"https?://", js), (
"no CDN — every reference is local (AGENTS.md rule 6)"
)
# The API surface: GET the catalogue, PUT one value, DELETE one /
# DELETE all (the phase-168 API contract, task 04).
assert 'fetch("/api/admin/settings")' in js, "the catalogue GET"
assert "fetch(`/api/admin/settings/${key}`" in js, (
"the per-key PUT / DELETE"
)
# The §7.4 lifecycle: the save button is aria-busy + labelled
# while in flight, re-enabled in the same path (success AND
# failure — the saveInFlight guard).
assert 'setAttribute("aria-busy", "true")' in js
assert "removeAttribute(\"aria-busy\")" in js
assert 'textContent = "Saving…"' in js
def test_settings_js_dirty_tracking_and_stop_at_first_failure() -> None:
"""The K4 contract, pinned at source level: the dirty state is a
module Map (key → value); markDirty compares the control value
against the SERVED value (a masked field's served value is "" —
an empty input means "leave unchanged", never an accidental
wipe); Save walks the dirty rows IN DOM ORDER, PUTs { value },
and breaks at the first failure (the 422 detail lands in the
role=alert region, the load re-applies the remaining edits,
focus goes to the offending input); a successful save lands the
`Saved N settings.` status line + the restart-required
suffix."""
js = _read(ASSETS / "settings.js")
assert "const dirty = new Map();" in js, (
"the dirty state is a module Map"
)
mark = js.find("function markDirty(")
mark_body = js[mark:js.find("\n }", mark)]
assert 'item.secret ? "" : item.value ?? ""' in mark_body, (
"the served value is '' for a masked field (never an accidental wipe)"
)
save = js.find("async function saveSettings()")
save_body = js[save:js.find("\n }", save)]
assert "for (const row of dataRows())" in save_body, (
"Save walks the dirty rows in DOM order"
)
put_i = save_body.find("JSON.stringify({ value })")
break_i = save_body.find("break; // stop at the first failure (K4)")
assert 0 <= put_i < break_i, "the loop breaks at the first failure (K4)"
assert "showError(" in save_body, "the 422 detail lands in the error region"
assert "input.focus()" in save_body, (
"focus goes to the offending input (K4)"
)
assert '`Saved ${saved.length} settings.`' in save_body, (
"the one status line (role=status)"
)
assert "applies on the next restart" in save_body, (
"a saved restart-required row says so (the owner is never "
"told 'saved' and then sees nothing change)"
)
# The reset + clear-all contract: the DELETE one/all + the
# settled status lines.
assert "`${envVar} is back to the environment value.`" in js, (
"the per-row Reset status line"
)
assert "Cleared ${cleared} overrides." in js, (
"the Clear-all status line (N from the response)"
)
# The two-step confirm (the house history-confirm-* classes,
# focus to Yes, Escape / No cancels).
assert 'label.textContent = "Clear all overrides?"' in js
assert 'yes.className = "history-confirm-yes"' in js
assert 'no.className = "history-confirm-no"' in js
assert "yes.focus()" in js, "focus moves to Yes (keyboard confirm)"
assert "e.key === \"Escape\"" in js, "Escape cancels (the task contract)"
# ---------- styles.css: the settings block, no new :root tokens ----------
def test_styles_css_settings_block_uses_existing_tokens_only() -> None:
"""styles.css declares the .settings-table (full width — the
.git-sources-table language, no skinny list) + the three
.settings-src.is-* variants (SOLID existing tokens — B3) + the
dirty-row marker, and adds NO new :root variable (the phase-156
B3 rule — the badges are text, the color is decoration; a themed
page's #bor-theme override of the token handles the color)."""
css = _read(ASSETS / "styles.css")
assert "---------- Settings view (phase 168) ----------" in css, (
"the Settings view block must exist"
)
block = re.search(r"\.settings-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .settings-table"
assert "width: 100%" in block.group(1), (
"the catalogue is full width (no skinny list — PLAN §7.1)"
)
for variant, bg in (
("is-db", "var(--brand-soft)"),
("is-env", "var(--surface)"),
("is-default", "var(--line)"),
):
rule = re.search(
rf"\.settings-src\.{variant} \{{([\s\S]*?)\n\}}", css
)
assert rule, f"styles.css must style .settings-src.{variant}"
body = rule.group(1)
assert bg in body, f".settings-src.{variant} is the solid {bg} token (B3)"
# The badge is TEXT (B5): the text is never color-alone — the
# variant always pairs the background with an existing ink
# token (never a color declaration without a text color).
assert "color: var(--" in body, (
f".settings-src.{variant} carries an existing ink token (text, not color alone)"
)
# The dirty-row marker (the --brand left border on the row).
assert "settings-row.is-dirty" in css, (
"the dirty-row marker must exist (unsaved survives grayscale)"
)
dirty = re.search(r"\.settings-row\.is-dirty[^{]*\{([\s\S]*?)\n\}", css)
assert dirty and "var(--brand)" in dirty.group(1), (
"the dirty marker is the --brand left border"
)
# No new :root variable (B3, the phase-156 rule): the settings
# block must not declare a custom property — only var(--…)
# references of the pre-existing tokens (a themed page's
# #bor-theme override of the token handles the color).
settings_block = css[css.find("Settings view (phase 168)"): css.find(
"Shared page (phase 51"
)]
declarations = re.findall(r"^\s{2,}--[\w-]+\s*:", settings_block, re.M)
assert not declarations, (
f"the settings block must not declare :root variables (B3): "
f"{declarations}"
)
# The touch targets: the ≥44px rules (the house language) + the
# mobile 640px block stacks the search + action rows.
for selector in ("#settings-filter", ".settings-control"):
rule = re.search(rf"{re.escape(selector)} \{{([\s\S]*?)\n\}}", css)
assert rule, f"styles.css must style {selector}"
assert "min-height: 44px" in rule.group(1), f"{selector}: the ≥44px target"
mobile = re.search(
r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css
)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".settings-search { flex-direction: column; align-items: stretch; }" in mbody
assert "#settings-save { width: 100%; }" in mbody, (
"the Save pill is full width on phones (the ≥44px target stands)"
)
# ---------- the four Theme-bearing pages carry the Settings link ----------
def test_settings_link_ships_on_every_theme_bearing_page() -> None:
"""The phase-34 one-bar contract — the SAME nav ships on every
page (test_nav_consistency pins the header inventory PARITY
across the shell pages, the document viewer, and the login
page), so #nav-settings (ship-hidden, admin-only) must be in
the #app-nav of EVERY header-bearing page: the shell +
document.html + login.html + shared.html. The doc-edit flow
page ships the reduced header (no admin links at all) and is
out of the contract."""
for page in (
FRONTEND / "index.html",
FRONTEND / "document.html",
FRONTEND / "login.html",
FRONTEND / "shared.html",
):
text = _read(page)
# The page must carry BOTH links — the Theme link (the
# phase-91 precedent) and the Settings link (this task).
theme = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
assert theme, f"{page.name} must carry the #nav-theme nav link"
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', text)
assert match, (
f"{page.name} must carry the #nav-settings nav link (one-bar)"
)
link = match.group(0)
assert 'href="/settings.html"' in link, (
f"{page.name}: the Settings link's href"
)
assert "hidden" in link, (
f"{page.name}: #nav-settings ships hidden (admin-only)"
)
assert "is-active" not in link, (
f"{page.name}: no static active stamp on the Settings link"
)
# The Settings link sits AFTER the Theme link (the order
# test_nav_consistency's inventory compares).
assert text.find('id="nav-theme"') < text.find('id="nav-settings"'), (
f"{page.name}: the Settings link follows the Theme link"
)
+188
View File
@@ -0,0 +1,188 @@
"""Phase 168 (task 03): Save re-points the two in-process singletons.
The "takes effect on Save" contract (D3): ``invalidate_settings()``
clears the ``get_settings()`` cache (the ~70 per-use settings) AND runs
the registered reset hooks — ``app.api.chat.reset_runtime_caches``
(nulls the shared ``LLMClient`` and the chat concurrency semaphore,
re-registered by ``create_app()`` at the composition root because
``app/core`` must never import ``app/api``, PLAN §3). These pins are
unit level: the chat globals are set directly, the overlay hooks are
registered directly (the autouse fixture gives every test a clean hook
list and restores every global it touches — a leaked ``LLMClient``
holding a test settings snapshot would poison the next suite's
singleton).
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
import app.main as app_main
from app.api import chat as chat_api
from app.config import get_settings, invalidate_settings
from app.core import settings_overlay
@pytest.fixture(autouse=True)
def _clean_state() -> Iterator[None]:
"""Clean hook list + saved chat globals, restored on teardown.
``reset_state_for_tests()`` clears the process-wide hook list (a
prior suite's test may have wiped the hook ``app.main`` registered
at import — these tests register their own); the ``chat_api``
globals are saved/restored around the test so a built ``LLMClient``
or semaphore never leaks into another test's singleton slot.
"""
settings_overlay.reset_state_for_tests()
saved = {
"_llm": chat_api._llm,
"_chat_semaphore": chat_api._chat_semaphore,
"_chat_active": chat_api._chat_active,
}
yield
chat_api._llm = saved["_llm"]
chat_api._chat_semaphore = saved["_chat_semaphore"]
chat_api._chat_active = saved["_chat_active"]
settings_overlay.reset_state_for_tests()
# monkeypatch restores BOR_* env first (finalized before this
# fixture) — the cache clear re-resolves to the conftest-pinned
# env-only view.
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# reset_runtime_caches — nulls exactly the two settings snapshots
# ---------------------------------------------------------------------------
def test_reset_nulls_llm_and_semaphore() -> None:
chat_api._llm = object()
chat_api._chat_semaphore = object()
chat_api._chat_active = 7
chat_api.reset_runtime_caches()
assert chat_api._llm is None
assert chat_api._chat_semaphore is None
# _chat_active counts live turns — a reset must not touch it (the
# new chat_max_concurrent applies as permits are taken).
assert chat_api._chat_active == 7
def test_reset_is_idempotent() -> None:
chat_api.reset_runtime_caches()
chat_api.reset_runtime_caches() # a double Save must not raise
assert chat_api._llm is None
assert chat_api._chat_semaphore is None
# ---------------------------------------------------------------------------
# get_llm / _get_chat_semaphore — lazy rebuild from the CURRENT settings
# ---------------------------------------------------------------------------
def test_get_llm_singleton_contract() -> None:
chat_api._llm = None
first = chat_api.get_llm()
second = chat_api.get_llm()
# The singleton contract still holds — the same instance until a
# reset (the pin that stops an "always construct" shortcut that
# would break connection pooling).
assert first is second
def test_get_llm_rebuilds_after_reset() -> None:
chat_api._llm = None
first = chat_api.get_llm()
chat_api.reset_runtime_caches()
second = chat_api.get_llm()
assert second is not first
assert second.settings is get_settings()
def test_get_chat_semaphore_rebuilds_after_reset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
chat_api._chat_semaphore = None
base = chat_api._get_chat_semaphore()
base_value = base._value # pyright: ignore[reportPrivateUsage]
# Drive the cap to a value different from the current one (the
# env is the base — the kill switch is off, conftest pin), so the
# rebuild's new _value is unambiguous.
target = 3 if base_value != 3 else 5
monkeypatch.setenv("BOR_CHAT_MAX_CONCURRENT", str(target))
get_settings.cache_clear()
chat_api.reset_runtime_caches()
rebuilt = chat_api._get_chat_semaphore()
assert rebuilt is not base
assert rebuilt._value == target # pyright: ignore[reportPrivateUsage]
# restore: the env reverts via monkeypatch, and the fixture's cache
# clear re-resolves the settings.
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# the create_app() wiring — the composition root registers the hook
# ---------------------------------------------------------------------------
def test_create_app_wires_the_reset_hook() -> None:
"""``register_invalidator(reset_runtime_caches)`` inside
``create_app`` — the static pin that stops the wiring being
"cleaned up" (without it a Save silently stops retuning the LLM
client)."""
src = Path(app_main.__file__).read_text()
start = src.index("def create_app(")
end = src.index("\napp = create_app()", start)
body = src[start:end]
assert "register_invalidator(reset_runtime_caches)" in body
def test_app_core_imports_no_app_api() -> None:
"""Dependency direction (PLAN §3): ``app/core/`` never imports an
``app.api`` symbol — the hook exists so the composition root can
wire the reset without inverting it."""
core = Path(app_main.__file__).resolve().parent / "core"
for py in sorted(core.glob("*.py")):
src = py.read_text()
for line in src.splitlines():
stripped = line.strip()
assert not (
stripped.startswith("from app.api")
or stripped.startswith("import app.api")
), f"{py.name} imports app.api (PLAN §3 direction)"
# ---------------------------------------------------------------------------
# invalidate_settings — cache clear + hook run, a failed reset stands
# ---------------------------------------------------------------------------
def test_invalidate_runs_hooks_and_re_resolves(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
settings_overlay.register_invalidator(lambda: calls.append("hook"))
get_settings.cache_clear()
get_settings() # a cached hit under the ORIGINAL env
monkeypatch.setenv("BOR_APP_NAME", "Live-Apply-Check")
invalidate_settings() # clears the cache + runs the hooks
assert calls == ["hook"]
# the re-resolution sees the NEW env — the cache really was cleared
assert get_settings().app_name == "Live-Apply-Check"
def test_invalidate_survives_a_raising_hook(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed reset must not turn a committed save into a 500 — the
exception is logged, never propagated."""
def _boom() -> None:
raise RuntimeError("boom")
settings_overlay.register_invalidator(_boom)
with caplog.at_level(logging.ERROR, logger="app.settings"):
invalidate_settings() # must not raise
assert any("settings invalidator failed" in r.getMessage() for r in caplog.records)
+427
View File
@@ -0,0 +1,427 @@
"""Phase 168 (task 02): the DB-over-env overlay resolver.
Pure/unit level — no live DB: the ``load_overrides`` failure paths use
a stubbed engine, the kill switch is flipped via ``os.environ`` (it is
read per call), and the env base comes from the process environment
(pinned by ``tests/conftest.py`` — ``BOR_SETTINGS_DB=0``).
"""
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.config import Settings
from app.core import settings_overlay
from app.core.settings_overlay import (
_coerce,
enabled,
load_overrides,
merge,
register_invalidator,
reset_state_for_tests,
resolve_settings,
run_invalidators,
)
@pytest.fixture(autouse=True)
def _clean_state() -> None:
reset_state_for_tests()
# ---------------------------------------------------------------------------
# enabled() — the BOR_SETTINGS_DB kill switch (D6)
# ---------------------------------------------------------------------------
def test_enabled_matrix(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BOR_SETTINGS_DB", raising=False)
assert enabled() is True
for value in ("0", "false", "no", "off"):
monkeypatch.setenv("BOR_SETTINGS_DB", value)
assert enabled() is False, value
for value in ("1", "true"):
monkeypatch.setenv("BOR_SETTINGS_DB", value)
assert enabled() is True, value
# read per call — a flip is visible without any cache clear
monkeypatch.setenv("BOR_SETTINGS_DB", "off")
assert enabled() is False
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
assert enabled() is True
# ---------------------------------------------------------------------------
# _coerce — env-native strings; the one list[str] field is JSON
# ---------------------------------------------------------------------------
def test_coerce_scalars_are_raw_strings() -> None:
# pydantic-settings lax-coerces on Settings() — the raw string goes
# through exactly like the env var spelling.
assert _coerce("relevance_threshold", "0.55") == "0.55"
assert _coerce("llm_retries", "7") == "7"
assert _coerce("stream_thinking", "false") == "false"
assert _coerce("import_extensions", "md,txt") == "md,txt"
def test_coerce_suggestions_json() -> None:
assert set(settings_overlay.LIST_FIELDS) == {"suggestions"}
assert _coerce("suggestions", '["a", "b"]') == ["a", "b"]
with pytest.raises(ValueError):
_coerce("suggestions", "not a json array")
# ---------------------------------------------------------------------------
# merge — layer rows over the env base, fail soft per key (LOCKED A5)
# ---------------------------------------------------------------------------
def test_merge_happy_path_env_base() -> None:
base = Settings()
merged, skipped = merge({"relevance_threshold": "0.55"})
assert skipped == []
assert merged.relevance_threshold == 0.55
assert isinstance(merged.relevance_threshold, float)
# everything else is byte-equal to the env base
for name in Settings.model_fields:
if name != "relevance_threshold":
assert getattr(merged, name) == getattr(base, name), name
def test_merge_skips_unknown_and_non_overridable() -> None:
base = Settings()
merged, skipped = merge(
{
"no_such_field": "x", # orphan row from a removed setting
"database_url": base.database_url, # LOCKED A3 — read-only
"embedding_dim": "512", # LOCKED A3 — read-only
}
)
keys = {key for key, _ in skipped}
assert keys == {"no_such_field", "database_url", "embedding_dim"}
# the base values stand — a hand-inserted row never hijacks them
assert merged.database_url == base.database_url
assert merged.embedding_dim == base.embedding_dim
assert merged.embedding_dim == 768
def test_merge_drops_one_invalid_value_keeps_the_rest() -> None:
base = Settings()
merged, skipped = merge(
{
"relevance_threshold": "not-a-float",
"llm_retries": "7",
}
)
assert [key for key, _ in skipped] == ["relevance_threshold"]
assert merged.llm_retries == 7
# the bad row falls back to the env/base value
assert merged.relevance_threshold == base.relevance_threshold
def test_merge_malformed_suggestions_json_is_skipped_not_fatal() -> None:
base = Settings()
merged, skipped = merge({"suggestions": "definitely not json"})
assert [key for key, _ in skipped] == ["suggestions"]
assert merged.suggestions == base.suggestions
assert isinstance(merged.suggestions, list)
def test_merge_valid_suggestions_json_overrides() -> None:
merged, skipped = merge({"suggestions": '["only this"]'})
assert skipped == []
assert merged.suggestions == ["only this"]
def test_merge_cross_field_violation_skipped_not_raised(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.30")
monkeypatch.setenv("BOR_LEXICAL_SUPPORT_FLOOR", "0.15")
# floor 0.9 > threshold 0.55 — the validator's cross-field guard
# (app/config.py) fires; the FLOOR row is dropped, the threshold
# override stands, the floor falls back to the env value.
merged, skipped = merge(
{"relevance_threshold": "0.55", "lexical_support_floor": "0.9"}
)
keys = {key for key, _ in skipped}
assert "lexical_support_floor" in keys
assert merged.relevance_threshold == 0.55
assert merged.lexical_support_floor == 0.15 # env fallback
def test_merge_env_collision_cross_field_terminates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The env floor (0.15) is above the DB-overridden threshold (0.1):
# the validator blames the FLOOR, which has no override — its value
# comes from env and cannot be dropped. The resolver must find a
# single override drop that heals the set (the threshold row)
# instead of looping: one unresolvable collision costs the
# colliding row, the rest of the table stands (LOCKED A5 fail-soft).
monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.30")
monkeypatch.setenv("BOR_LEXICAL_SUPPORT_FLOOR", "0.15")
base = Settings()
merged, skipped = merge({"relevance_threshold": "0.1"})
keys = {key for key, _ in skipped}
assert "relevance_threshold" in keys
assert merged.relevance_threshold == base.relevance_threshold
assert merged.lexical_support_floor == base.lexical_support_floor
def test_merge_bool_spellings_behave_like_env() -> None:
merged_false, skipped = merge({"stream_thinking": "false"})
assert skipped == []
assert merged_false.stream_thinking is False
merged_zero, _ = merge({"stream_thinking": "0"})
assert merged_zero.stream_thinking is False
# untouched fields keep the base value
base = Settings()
assert merged_false.llm_retries == base.llm_retries
def test_merge_empty_rows_is_the_env_base() -> None:
base = Settings()
merged, skipped = merge({})
assert skipped == []
assert merged == base
def test_load_overrides_success_path(monkeypatch: pytest.MonkeyPatch) -> None:
class _Rows:
def __iter__(self):
return iter((("llm_retries", "9"), ("stream_thinking", "false")))
class _FakeConn:
def execute(self, _stmt: object) -> _Rows:
return _Rows()
def __enter__(self):
return self
def __exit__(self, *exc: object) -> bool:
return False
class _FakeEngine:
def connect(self) -> _FakeConn: # type: ignore[no-untyped-def]
return _FakeConn()
monkeypatch.setattr(settings_overlay, "_get_engine", lambda: _FakeEngine())
assert load_overrides() == {"llm_retries": "9", "stream_thinking": "false"}
def test_get_engine_creates_once_and_caches() -> None:
# create_engine is lazy (no connection at creation) — the second
# call must return the SAME engine (the once-per-process engine).
first = settings_overlay._get_engine()
second = settings_overlay._get_engine()
assert first is second
# ---------------------------------------------------------------------------
# merge — the defensive fallback (a validator change in a later release
# blames a field no override owns and no single drop heals the set: the
# env base stands, logged)
# ---------------------------------------------------------------------------
def test_merge_unresolvable_collision_returns_base_logged(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typing import Any, cast
from pydantic import ValidationError
class _StubSettings:
"""Raises like a broken candidate set; the final no-args call
(the env-base fallback) succeeds."""
model_fields = Settings.model_fields # merge() pre-checks it
calls = 0
def __init__(self, **_kwargs: object) -> None:
_StubSettings.calls += 1
if _StubSettings.calls < 3:
# the runtime shape (msg/error keys) is wider than the
# pyright stub's InitErrorDetails — cast it
raise ValidationError.from_exception_data(
"Settings",
cast(
Any,
[
{
"type": "missing",
"loc": ("lexical_support_floor",),
"msg": "a later-release validator",
"error": ValueError("a later-release validator"),
"input_value": 0.35,
}
],
),
)
monkeypatch.setattr(settings_overlay, "Settings", _StubSettings)
merged, skipped = merge({"relevance_threshold": "0.1"})
assert merged is not None # the env base (the stub's final call)
reasons = {key: reason for key, reason in skipped}
assert "unresolvable cross-field collision" in (
reasons.get("relevance_threshold", "")
or reasons.get("lexical_support_floor", "")
)
# ---------------------------------------------------------------------------
# load_overrides — fail-soft, once-per-process (K3 + D5)
# ---------------------------------------------------------------------------
class _FailingEngine:
"""A stub engine whose connect() raises the given exception."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
def connect(self) -> None: # type: ignore[no-untyped-def]
raise self._exc
def test_load_overrides_generic_failure_warns_once(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(
settings_overlay,
"_get_engine",
lambda: _FailingEngine(
OperationalError("SELECT 1", {}, OSError("connection refused"))
),
)
with caplog.at_level(logging.WARNING, logger="app.settings"):
assert load_overrides() == {}
assert load_overrides() == {} # the second miss is silent
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "settings overrides unavailable — env values in effect" in warnings[0].msg
# the exception class is named, the value is not
assert "OperationalError" in warnings[0].getMessage()
def test_load_overrides_missing_table_debugs_once(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(
settings_overlay,
"_get_engine",
lambda: _FailingEngine(
ProgrammingError(
"SELECT key, value FROM app_settings",
{},
Exception('relation "app_settings" does not exist'),
)
),
)
with caplog.at_level(logging.DEBUG, logger="app.settings"):
assert load_overrides() == {}
assert load_overrides() == {} # the second miss is silent
debugs = [
r
for r in caplog.records
if r.levelno == logging.DEBUG and "app_settings" in r.getMessage()
]
assert len(debugs) == 1
def test_reset_state_restores_the_warning(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(
settings_overlay,
"_get_engine",
lambda: _FailingEngine(
OperationalError("SELECT 1", {}, OSError("connection refused"))
),
)
with caplog.at_level(logging.WARNING, logger="app.settings"):
load_overrides() # first miss: warns
reset_state_for_tests() # clears the once-per-process flag
load_overrides() # second miss: warns again
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 2
# ---------------------------------------------------------------------------
# resolve_settings — the whole overlay, recursion guard (D2)
# ---------------------------------------------------------------------------
def test_resolve_settings_disabled_returns_env_base(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
assert resolve_settings() == Settings()
def test_resolve_settings_enabled_uses_the_rows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
monkeypatch.setattr(
settings_overlay, "load_overrides", lambda: {"llm_retries": "9"}
)
settings = resolve_settings()
assert settings.llm_retries == 9
def test_resolve_settings_never_reaches_back_into_config() -> None:
"""The recursion guard, source-pinned: the overlay builds
``Settings()`` directly — app.config's resolver delegates HERE."""
src = Path(settings_overlay.__file__).read_text()
assert "get_settings(" not in src
# ---------------------------------------------------------------------------
# the invalidator registry (task 03's seam)
# ---------------------------------------------------------------------------
def test_invalidators_run_in_order_and_survive_a_raising_hook(
caplog: pytest.LogCaptureFixture,
) -> None:
calls: list[str] = []
def _boom() -> None:
raise RuntimeError("boom")
register_invalidator(lambda: calls.append("first"))
register_invalidator(_boom)
register_invalidator(lambda: calls.append("third"))
with caplog.at_level(logging.ERROR, logger="app.settings"):
run_invalidators() # must not raise
assert calls == ["first", "third"]
assert any(
"settings invalidator failed" in r.getMessage() for r in caplog.records
)
# ---------------------------------------------------------------------------
# LOCKED A1 — .env is never written by the app
# ---------------------------------------------------------------------------
def test_phase_files_never_write_the_env_file() -> None:
"""No ``open(`` / ``write_text(`` in the phase-168 modules — the
panel writes Postgres only (LOCKED A1); ``app/api/admin_settings.py``
(task 04) is pinned in the same way once it exists."""
root = Path(settings_overlay.__file__).resolve().parents[2]
for rel in (
"app/core/settings_overlay.py",
"app/config.py",
"app/api/admin_settings.py",
):
path = root / rel
if not path.exists():
continue
src = path.read_text()
assert "open(" not in src, (
f"{rel} must never open() a file (LOCKED A1: .env is never "
f"written by the app)"
)
assert "write_text(" not in src, f"{rel} must never write a file (LOCKED A1)"