Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
7.2 KiB
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) — thesaved_chatsrow,/api/chatsCRUD, the?chat=<id>boot load, the linked-row Save upsert (saveCurrentChatinapp.js).51_share_chat(complete) — the public/shared/<token>snapshot read (stays a frozen snapshot; untouched by this phase).49_retry_answer(complete) — theretryLastTurnredo-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 beyondstyles.css/app.jsregions).
Tasks
01_sources_version_and_migration.md— the single-rowsources_metatable +saved_chats.sources_version(migration 0010) + the current/bump helpers + the migration test.02_sync_version_bump.md— the version bump on both sync paths (Sync button + CLI), change-gated.03_chats_api_staleness.md— stamp-on-save + thestaleflag on the list/detail responses.04_history_stale_badge.md— the Stale column on the History table.05_stale_banner_and_regenerate.md— the chat-page banner + Regenerate (retryLastTurnreuse) + the auto re-save.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+ thetests/fakes.pyoverride patterns) and the CLI coverage (tests/integration/test_import_docs_overview.pypins the change-gating pattern; the bump asserts sit alongside). - Integration:
tests/integration/test_migration_0010.py(house pattern, fromtest_migration_0009.py); extendtests/integration/test_chats_api.py(stamp +staleflag + share-unshare version immunity). - Frontend source pins (house style):
history.jsstale-cell branch,app.jsbanner reveal / Regenerate wiring / post-regenerate persist / no-brain-bubble guard (thetest_history_page.py/test_save_chat_ui.pypin 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.versionexactly once; an unchanged re-run, a--limitdebug run, and a FAILED sync never bump. - A Save/Re-Save stamps the row's
sources_version;GET /api/chats+GET /api/chats/<id>exposestale(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>reportsstale: false, the History marker is gone. - A
/shared/<token>page is unchanged — the public snapshot carries no staleness surface. uv run pytestgreen; coverage TOTAL >90%.uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-covgreen 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 pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/.
Locked decisions
- Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):
- The invalidation marker is a monotonically increasing
sources_meta.version(single row, thekb_overviewid=1 precedent), bumped ONLY when a sync changed the KB — the gate isadded + updated + pruned > 0(pruned counts: a deleted doc can invalidate an answer that cited it — deliberately broader than the overview gate'sadded + updated > 0). 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.- 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.
- The public shared snapshot (
/shared/<token>) is deliberately untouched — a frozen snapshot by design; an owner who regenerates can re-share afterwards. - 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).
- The invalidation marker is a monotonically increasing
- A10 honoured —
/api/chatstays 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
git add -A .agents/ 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"