fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them

This commit is contained in:
2026-08-31 01:35:28 -04:00
parent 9518d9d5d1
commit c564e317ed
6 changed files with 742 additions and 81 deletions
@@ -1,56 +0,0 @@
# Phase 53 — Invalidate Saved Chats on Sources Sync
**Source:** `TODO.md` L4 — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data"
**Story:** n/a (TODO-derived — owner instruction 2026-08-30: convert without confirmation)
**Context:** Phase 50 stores explicitly saved conversations in `saved_chats` (JSONB `bor.chat.v1` records; admin-only CRUD under `/api/chats` in `app/api/chats.py`; the History page is a full-width table per AGENTS.md rule 5; `/?chat=<id>` re-opens a row pixel-identical through the phase-14 restore path). Phase 51 added the public snapshot read (`/shared/<token>` → `SharedChatOut`, no admin dependency). Sources are synced through two canonical paths: the admin **Sync** button (`POST /api/sync` → `app/api/sync.py::_run_sync`: `check_models` → resolve effective sources → clone/pull or local-dir re-verify → `import_sources(prune=True)` → change-gated `regenerate_overview`) and the CLI/quadlet `scripts/import_docs.py` (same `import_sources`; `--limit` debug runs and unchanged re-imports are change-gated on `added + updated`). Neither path records *when the KB last changed*, so a saved answer can silently predate the current index. `retryLastTurn(wrap)` in `frontend/assets/app.js` (phase 49) is the existing redo-in-place mechanism for the LAST brain bubble (re-asks the preceding user question, `runTurn(text, { reask: true })`, no user append, no scroll) — the Regenerate action reuses it. Single-row table precedent: `kb_overview` (id = 1, `app/models.py::KbOverview`).
## Objective
Every sync that actually changes the knowledge base bumps a sources version; saved chats stamp that version at save time; a chat saved against an older version is surfaced as **stale** (History table badge + a banner when opened) with a **Regenerate** action that re-asks the last question against the new index and re-saves the row — a stale answer can no longer masquerade as current.
## Dependencies
- `50_chat_history` (complete) — the `saved_chats` row, `/api/chats` CRUD, the `?chat=<id>` boot load, the linked-row Save upsert (`saveCurrentChat` in `app.js`).
- `51_share_chat` (complete) — the public `/shared/<token>` snapshot read (stays a frozen snapshot; untouched by this phase).
- `49_retry_answer` (complete) — the `retryLastTurn` redo-in-place the Regenerate action drives.
- `52_pinned_composer` (todo, preceding) — the chat-page layout is stable while the banner lands; ordering keeps the chat UI quiet (no shared-file conflict beyond `styles.css`/`app.js` regions).
## Tasks
1. `01_sources_version_and_migration.md` — the single-row `sources_meta` table + `saved_chats.sources_version` (migration 0010) + the current/bump helpers + the migration test.
2. `02_sync_version_bump.md` — the version bump on both sync paths (Sync button + CLI), change-gated.
3. `03_chats_api_staleness.md` — stamp-on-save + the `stale` flag on the list/detail responses.
4. `04_history_stale_badge.md` — the Stale column on the History table.
5. `05_stale_banner_and_regenerate.md` — the chat-page banner + Regenerate (`retryLastTurn` reuse) + the auto re-save.
6. `06_e2e_stale_saved_chats.md` — the story Playwright suite + regressions + commit.
## Testing & Quality
- Unit: `tests/unit/test_sources_meta.py` (helpers: absent row → 0, first bump → 1, second bump → 2, idempotent reads); the sync bump gates — extend the existing sync coverage (`tests/integration/test_sync_api.py` + the `tests/fakes.py` override patterns) and the CLI coverage (`tests/integration/test_import_docs_overview.py` pins the change-gating pattern; the bump asserts sit alongside).
- Integration: `tests/integration/test_migration_0010.py` (house pattern, from `test_migration_0009.py`); extend `tests/integration/test_chats_api.py` (stamp + `stale` flag + share-unshare version immunity).
- Frontend source pins (house style): `history.js` stale-cell branch, `app.js` banner reveal / Regenerate wiring / post-regenerate persist / no-brain-bubble guard (the `test_history_page.py` / `test_save_chat_ui.py` pin patterns).
- Coverage: **>90%** on `app/` (validate.sh gate).
- E2E (mandatory, A16): `tests/e2e/test_stale_saved_chats.py`, run in isolation.
## Completion Criteria
- [ ] A KB-changing sync (button or CLI) bumps `sources_meta.version` exactly once; an unchanged re-run, a `--limit` debug run, and a FAILED sync never bump.
- [ ] A Save/Re-Save stamps the row's `sources_version`; `GET /api/chats` + `GET /api/chats/<id>` expose `stale` (true iff the row's version is behind the current one); share/unshare never touch the version.
- [ ] The History table shows the Stale marker exactly on rows saved before the last KB-changing sync (full-width table geometry unchanged, AGENTS.md rule 5).
- [ ] Opening a stale `/?chat=<id>` shows the banner; Regenerate re-streams the last answer in place against the new index and re-saves the row — the banner clears, `GET /api/chats/<id>` reports `stale: false`, the History marker is gone.
- [ ] A `/shared/<token>` page is unchanged — the public snapshot carries no staleness surface.
- [ ] `uv run pytest` green; coverage TOTAL >90%.
- [ ] `uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_chat_history.py`, `test_share_chat.py`, `test_sync_button.py`, `test_retry_answer.py`, `test_chat_persistence.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Locked decisions
- **Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):**
1. The invalidation marker is a monotonically increasing `sources_meta.version` (single row, the `kb_overview` id=1 precedent), bumped ONLY when a sync changed the KB — the gate is `added + updated + pruned > 0` (pruned counts: a deleted doc can invalidate an answer that cited it — deliberately broader than the overview gate's `added + updated > 0`).
2. `stale = row.sources_version < current`, computed server-side in the chats API; the client never computes staleness. Pre-existing rows stamp 0 (the pre-counter KB) and go stale on the first bump.
3. Regenerate = the phase-49 redo-in-place of the LAST brain bubble (re-ask the last user question with the full conversation context) followed by an auto re-save of the linked row — the owner does not press Save again; a stale chat with no brain answer shows the banner text without a Regenerate button.
4. The public shared snapshot (`/shared/<token>`) is deliberately untouched — a frozen snapshot by design; an owner who regenerates can re-share afterwards.
5. A failed sync (git/embed/import error) aborts BEFORE the bump — a failed sync never invalidates chats; the bump commits even if the best-effort overview regeneration then fails (the index really did change).
- **A10 honoured** — `/api/chat` stays stateless; staleness is a property of the explicitly saved row, not of the chat endpoint.
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
## Commit
```bash
git add -A .agent/ app/ alembic/versions/ scripts/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index"
```