feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+66
View File
@@ -0,0 +1,66 @@
"""sources_meta + saved_chats.sources_version: the KB-generation stamp (phase 53)
Revision ID: 0010
Revises: 0009
Create Date: 2026-08-30
Phase 53 (invalidate saved chats on sources sync, TODO-derived
2026-08-30): a saved answer can silently predate the current index,
because neither sync path (the admin Sync button,
``scripts/import_docs.py``) records *when the KB last changed*. ONE
migration carries the whole feature (A13 — one feature, one atomic,
reversible schema change):
* ``sources_meta`` — a **single-row** counter (``id INTEGER PK
DEFAULT 1``, the ``kb_overview`` phase-31 precedent) holding the
current **generation** of the knowledge base. The seed row
(id 1, version 0) is inserted here — not lazily on first bump — so
``app.rag.sources_meta.current_sources_version`` is a plain PK read.
``version`` is bumped exactly once per KB-changing sync (task 02,
change-gated on ``added + updated + pruned > 0``); a failed sync
never bumps.
* ``saved_chats.sources_version`` — ``INTEGER NOT NULL DEFAULT 0``:
the generation the conversation was saved against, stamped by the
chats API at save time (task 03). Pre-0010 rows come back stamped 0
(the pre-counter KB) and go stale on the first bump.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sources_meta",
sa.Column("id", sa.Integer(), primary_key=True, server_default=sa.text("1")),
sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
# Seed the single row (id 1, version 0): "the pre-counter KB".
op.execute(
"INSERT INTO sources_meta (id, version, updated_at) VALUES (1, 0, now())"
)
op.add_column(
"saved_chats",
sa.Column(
"sources_version", sa.Integer(), nullable=False, server_default=sa.text("0")
),
)
def downgrade() -> None:
# Safe order: drop the stamp column first, then the counter table.
op.drop_column("saved_chats", "sources_version")
op.drop_table("sources_meta")
+67 -14
View File
@@ -15,16 +15,31 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` so the History's Share column renders without a
second fetch), ``POST`` (create — auto-title from the first question
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
History's Share and Stale columns render without a second fetch),
``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract),
the token in the SAME commit — the save-then-share contract;
(phase 53, task 03) stamps the row's ``sources_version`` on the
PENDING row so it ships in the same INSERT),
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
only when supplied; re-stamps ``sources_version`` to the current
generation — a Re-Save is the owner affirming this content against
the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
Staleness (phase 53, task 03): every saved row carries the
``sources_meta`` generation it was saved against (``sources_version``,
migration 0010). ``GET``/list/detail expose ``stale`` — true iff the
row's stamp is behind the current generation, computed server-side
from ONE ``current_sources_version`` read per request (the client
never does staleness math). Share/unshare stay version-immune (raw SQL
on ``share_token`` only — the version, like ``updated_at``, is
untouched); ``SharedChatOut`` (the public snapshot) carries no
staleness surface at all — it is frozen by design (phase 51).
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
publicly readable at ``/shared/<token>`` — two more routers in this
@@ -54,6 +69,7 @@ from app.config import get_settings
from app.core.auth import require_admin
from app.db import get_db
from app.models import SavedChat
from app.rag.sources_meta import current_sources_version
from app.schemas import (
ChatMessage,
SavedChatCreate,
@@ -100,8 +116,14 @@ def _share_url(row: SavedChat) -> str | None:
return f"/shared/{row.share_token}" if row.share_token else None
def _to_out(row: SavedChat) -> SavedChatOut:
"""The full-payload response shape (create/get/put)."""
def _to_out(row: SavedChat, current_version: int) -> SavedChatOut:
"""The full-payload response shape (create/get/put).
``current_version`` is the caller's ONE per-request
``current_sources_version`` read — the helpers stay pure (no session
argument, no second query); ``stale`` is true iff the row's stamp
is behind that generation.
"""
return SavedChatOut(
id=row.id,
title=row.title,
@@ -110,18 +132,22 @@ def _to_out(row: SavedChat) -> SavedChatOut:
message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages],
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
def _to_row(row: SavedChat) -> SavedChatRow:
def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
"""The list-page row shape (no payloads in the list; ``share_url``
is not a payload — it is the History Share column's data)."""
and ``stale`` are not payloads — they are the History Share and
Stale columns' data). ``current_version`` is the caller's ONE
per-request read (see :func:`_to_out`)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
@@ -130,11 +156,15 @@ def list_chats(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order."""
desc``) — the History page's table order. Each row carries the
phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries."""
current_version = current_sources_version(db)
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
).all()
return SavedChatList(chats=[_to_row(row) for row in rows])
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
@router.post("", response_model=SavedChatOut, status_code=201)
@@ -153,8 +183,15 @@ def create_chat(
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
INSERT/commit — one request saves AND shares, and the 201 body
carries ``share_url`` (the chat page copies it in one action).
``sources_version`` (phase 53, task 03): stamped on the PENDING
row with the current generation — it ships in the SAME INSERT (the
``share_token`` precedent), so a save can never be committed
unstamped, and the 201 body's ``stale`` flag (freshly stamped →
always false) is honest by construction.
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
current_version = current_sources_version(db) # once per request
row = SavedChat(
title=title,
# Plain model_dump (no exclude_none): the stored JSONB keeps
@@ -163,6 +200,9 @@ def create_chat(
# is what makes a real payload round-trip byte-identical (the
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
# Phase 53: the KB generation this save is made against, set
# before db.add so it rides the same INSERT (see above).
sources_version=current_version,
)
if payload.share:
# Phase 51: set on the PENDING row, so the token ships in the
@@ -174,7 +214,7 @@ def create_chat(
row.title = f"Chat {row.id.hex[:8]}"
db.commit()
db.refresh(row)
return _to_out(row)
return _to_out(row, current_version)
@router.get("/{chat_id}", response_model=SavedChatOut)
@@ -183,11 +223,13 @@ def get_chat(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
the id is unknown."""
the id is unknown. The ``stale`` flag (phase 53) tells the chat
page whether to reveal its stale banner (task 05) before the
messages render."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
return _to_out(row)
return _to_out(row, current_sources_version(db))
@router.put("/{chat_id}", response_model=SavedChatOut)
@@ -203,17 +245,28 @@ def update_chat(
bumps via the model's ``onupdate=func.now()`` — the attribute
assignment above is the ORM change that triggers it, so the History
page's "latest activity first" order follows re-Saves.
``sources_version`` is re-stamped to the CURRENT generation
unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS
re-stamps, not "only when stale"): re-Saving is the owner
affirming this content against the current KB, which makes the
response's ``stale`` flag false and doubles as the manual escape
hatch for a false-positive stale row (one fewer code path than a
conditional re-stamp).
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
current_version = current_sources_version(db) # once per request
row.messages = [m.model_dump() for m in payload.messages]
title = (payload.title or "").strip()
if title:
row.title = title
# Phase 53: the affirmation stamp — see the docstring above.
row.sources_version = current_version
db.commit()
db.refresh(row)
return _to_out(row)
return _to_out(row, current_version)
@router.delete("/{chat_id}", status_code=204)
+32 -1
View File
@@ -39,7 +39,16 @@ decisions):
CLI's no-prune default is unchanged);
5. when the import changed the KB (added + updated > 0),
``regenerate_overview`` refreshes the single ``kb_overview`` row
(phase 31 trigger, best-effort inside).
(phase 31 trigger, best-effort inside);
6. when the import changed the KB (added + updated + pruned > 0 — the
saved-chat invalidation gate, phase 53 task 02: a pruned document
can invalidate a saved answer that cited it, deliberately broader
than step 5's overview gate), the single-row ``sources_meta``
version counter is bumped exactly once in a short-lived session and
the resulting generation lands in the status detail as
``sources_version`` (an unchanged re-sync reports the current
generation without advancing it). A FAILED sync never bumps — the
run aborts in the ``failed`` state before this step.
Status is in memory: a restart mid-sync loses the running state
(accepted — the next click re-syncs idempotently).
@@ -63,6 +72,7 @@ from app.rag.git_sources import effective_sources
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient, check_models
from app.rag.overview import regenerate_overview
from app.rag.sources_meta import bump_sources_version, current_sources_version
from scripts.git_sync import GitSyncError, clone_or_pull
from scripts.import_docs import repo_name
@@ -202,6 +212,26 @@ async def _run_sync() -> None:
overview = False
if summary.added + summary.updated > 0:
overview = await regenerate_overview(llm)
# Phase 53 (task 02): a sync that changed the KB advances the
# sources version exactly once — the saved-chat invalidation
# marker (task 03 stamps rows against it). The gate is
# deliberately broader than the overview's above: a pruned
# document can invalidate a saved answer that cited it, so
# ``pruned > 0`` bumps too. The bump commits in its own short
# session (the ``effective_sources`` pattern above), so it
# lands even if the best-effort overview then fails — the index
# really did change. An unchanged re-sync never bumps; it
# reports the current generation instead, so the detail always
# carries the generation the KB is now at.
db = SessionLocal()
try:
if summary.added + summary.updated + summary.pruned > 0:
sources_version = bump_sources_version(db)
db.commit()
else:
sources_version = current_sources_version(db)
finally:
db.close()
_status.state = "success"
_status.finished_at = datetime.now(UTC)
_status.detail = {
@@ -215,6 +245,7 @@ async def _run_sync() -> None:
"summaries": summary.summaries,
"summary_errors": summary.summary_errors,
"overview": overview,
"sources_version": sources_version,
}
logger.info("sync: done detail=%s", _status.detail)
except Exception as e: # noqa: BLE001 — a background task dies in state, see above
+42 -1
View File
@@ -23,7 +23,14 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
14 shape) — phase 50; ``/api/chat`` stays
stateless; ``share_token`` (NULL = private,
``uuid4`` = publicly readable at
``/shared/<token>``) — phase 51.
``/shared/<token>``) — phase 51; ``sources_version``
(the KB generation the conversation was saved
against; 0 = the pre-counter KB, stale on the
first bump) — phase 53.
* ``sources_meta`` — single-row sources-version counter: which
generation of the knowledge base is current,
bumped exactly once per KB-changing sync so saved
chats can be marked stale (phase 53).
"""
from __future__ import annotations
@@ -146,6 +153,33 @@ class KbOverview(Base):
)
class SourcesMeta(Base):
"""Single-row sources-version counter (phase 53).
Exactly one row (``id = 1``, seeded by migration 0010 — the
``kb_overview`` id=1 precedent) holds the current **generation** of
the knowledge base. ``version`` is bumped exactly once per sync that
actually changed the KB (phase 53, task 02 — change-gated on
``added + updated + pruned > 0``), so it doubles as the invalidation
marker for saved chats: a ``saved_chats`` row stamped with an older
generation is *stale* — its answers predate the current index and
may be Regenerated against it (phase 53, tasks 03/05). The row is
seeded by the migration (not lazily on first bump), so
:func:`app.rag.sources_meta.current_sources_version` is a plain PK
read.
"""
__tablename__ = "sources_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1")
#: The KB generation. 0 = the pre-counter KB (everything indexed
#: before phase 53); incremented by one per KB-changing sync.
version: Mapped[int] = mapped_column(Integer, server_default="0")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class GitSource(Base):
"""One admin-managed source (phase 35; kind discriminator, phase 38).
@@ -203,6 +237,13 @@ class SavedChat(Base):
share_token: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), unique=True, nullable=True
)
#: The sources version (KB generation) the conversation was saved
#: against (phase 53): stamped by the API at save time. Existing
#: rows (saved before the counter existed) stamp ``0`` — "the
#: pre-counter KB" — and become stale on the first KB-changing sync
#: (stale = ``sources_version < current``, computed server-side by
#: the chats API, phase 53 task 03).
sources_version: Mapped[int] = mapped_column(Integer, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+53
View File
@@ -0,0 +1,53 @@
"""Sources-version counter (phase 53, task 01).
The single-row ``sources_meta`` table (migration 0010, the
``kb_overview`` id=1 precedent) records **which generation of the
knowledge base** is current. ``version`` is bumped exactly once per
sync that actually changed the KB (task 02 — change-gated on
``added + updated + pruned > 0``; a failed sync never bumps), so it
doubles as the invalidation marker for saved chats: the chats API
stamps each saved row with the current version at save time (task 03)
and flags rows stamped with an older generation *stale* — their
answers predate the current index and may be Regenerated against it
(task 05).
Session convention (phase 53): :func:`bump_sources_version` only
*flushes* — the **caller** commits. The two sync paths (the admin Sync
button, ``scripts/import_docs``) each own their session, and the bump
must land in the same transaction as the KB changes it records.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models import SourcesMeta
def current_sources_version(db: Session) -> int:
"""The current KB generation (one PK read of the seeded row).
Returns ``0`` when the row is absent — defensive, never raises
(the migration seeds the row, so absence only happens if someone
deleted it out-of-band).
"""
row = db.get(SourcesMeta, 1)
if row is None:
return 0
return row.version
def bump_sources_version(db: Session) -> int:
"""Advance the KB generation by one; return the new version.
Upserts the single row (creating it if it was ever deleted),
increments ``version`` by one, and ``flush``es — the **caller**
commits, because each of the two sync paths owns its session and
the bump must commit together with the KB changes it witnessed.
"""
row = db.get(SourcesMeta, 1)
if row is None:
row = SourcesMeta(id=1, version=0)
db.add(row)
row.version += 1
db.flush()
return row.version
+16 -2
View File
@@ -387,7 +387,7 @@ def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHand
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50;
``share_url``, phase 51 task 02).
``share_url``, phase 51 task 02; ``stale``, phase 53 task 03).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
@@ -395,6 +395,13 @@ class SavedChatOut(BaseModel):
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
``stale`` (phase 53): true iff the row's ``sources_version`` stamp
is behind the current ``sources_meta`` generation — the answer
predates the latest KB-changing sync. Computed server-side (the
client never does staleness math); ``SharedChatOut`` deliberately
carries no staleness surface (the public snapshot is frozen by
design, phase 51).
"""
id: uuid.UUID
@@ -404,6 +411,9 @@ class SavedChatOut(BaseModel):
message_count: int
messages: list[ChatMessage]
share_url: str | None = None
#: Required (no default): the API must always compute staleness
#: server-side — there is no wire shape without the flag.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
@@ -418,7 +428,9 @@ class SavedChatRow(BaseModel):
(Title, Messages count, Updated). ``share_url`` is populated here so
the History page's Share column renders straight from ``GET
/api/chats`` — no second fetch per row (``None`` → absent, the same
omission rule as :class:`SavedChatOut`).
omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds
the History page's Stale column the same way: one ``GET`` powers
every column.
"""
id: uuid.UUID
@@ -426,6 +438,8 @@ class SavedChatRow(BaseModel):
updated_at: datetime
message_count: int
share_url: str | None = None
#: Required (no default) — see :attr:`SavedChatOut.stale`.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
+126 -2
View File
@@ -177,6 +177,32 @@
* in hint, like Save); a network failure → the "is the app reachable?"
* banner.
*
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the
* knowledge base bumps the sources generation; a row saved against an
* older one is STALE (server-computed `stale` on GET /api/chats/<id> —
* the client never does staleness math). The /?chat=<id> boot load
* reveals the #stale-banner (top of the column, directly below
* #kb-banner) when the fetched payload reports `stale: true`. A stale
* conversation with NO brain record is revealed text-only — the
* #stale-regenerate button is removed (retryLastTurn is never called in
* that state). Regenerate = the phase-49 redo-in-place of the LAST
* brain bubble ONLY: retryLastTurn(lastBrainWrap) re-asks the last
* question against the new index (full conversation context kept; earlier
* answers are not re-run), and retryLastTurn now RETURNS the
* runTurn promise so the handler can await the turn's completion
* (behavior-neutral for the existing Retry click, which ignores it).
* Only when the turn completes WITHOUT the error banner does the handler
* persist the linked row through the SAME upsert as Save — PUT
* /api/chats/<id> (the server re-stamps sources_version → stale: false);
* a 404 (row deleted from History meanwhile) unlinks and recreates
* (saveCurrentChat's stale-link rule). A regenerate that errors
* mid-stream leaves the row untouched (stale stays true); a regenerate
* STOPPED mid-stream (phase 48) persists the stopped partial. Success
* hides the banner and announces in the #send-status live region
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
* a successful manual re-Save (both make the row/conversation no longer
* the one the banner describes).
*
* All DOM ids match frontend/index.html.
*/
@@ -201,6 +227,8 @@ const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
* (the classic assets/brand.js sets it at parse time; its /api/config
@@ -1100,6 +1128,18 @@ async function restoreSavedChatFromUrl() {
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
currentChatId = chatId; // linked: a subsequent Save updates THIS row
saveConversation(); // mirror to localStorage — a plain refresh returns here
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
// the row's sources stamp is behind the current generation; the
// client never does staleness math). Reveal the banner; when the
// conversation has NO brain record there is nothing to regenerate,
// so the button is removed first (text-only — retryLastTurn is never
// called in that state).
if (data.stale === true) {
if (!conversation.some((m) => m.who === "brain") && staleRegenBtn) {
staleRegenBtn.remove(); // no brain answer — nothing to regenerate
}
if (staleBanner) staleBanner.hidden = false;
}
// The ?chat= param is a one-shot boot instruction: normalize the URL
// back to / so a later refresh / "New chat" + refresh restores the
// LOCAL session (the mirror above) instead of re-opening this row.
@@ -1151,6 +1191,9 @@ async function saveCurrentChat() {
currentChatId = String(created.id); // fresh Save: link to the new row
}
sendStatus.textContent = "Conversation saved.";
// Phase 53: a re-Save re-stamps the row to the current generation
// (task 03) — the row is no longer stale, so the banner is done.
if (staleBanner) staleBanner.hidden = true;
} catch {
showErrorBanner("Couldn't save the conversation — is the app reachable?");
} finally {
@@ -1268,6 +1311,75 @@ async function shareCurrentChat() {
}
}
/* Regenerate a stale saved chat — the #stale-regenerate handler
* (phase 53, task 05). The banner only ever shows on the /?chat=<id>
* boot path (admin), so currentChatId is set whenever this runs. The
* redo: retryLastTurn(lastBrainWrap) — the phase-49 redo-in-place of
* the LAST brain bubble (its own guards — in-flight, wrap !==
* lastBrainWrap, no preceding user record — make a stale or superseded
* click a no-op that resolves nothing). The handler AWAITs the returned
* turn promise, and only when the turn completed WITHOUT the error
* banner persists the linked row through the SAME upsert as Save:
* PUT /api/chats/<id> (the server re-stamps sources_version → the row
* is fresh again); a 404 (the row was deleted from History meanwhile)
* follows saveCurrentChat's stale-link rule — unlink + recreate, so the
* owner is never left with an unsaved conversation. A regenerate that
* errors mid-stream leaves the row untouched (stale stays true —
* Regenerate stays available); a regenerate STOPPED mid-stream (phase
* 48) persists the stopped partial (the owner engaged with the new
* index). Success hides the banner and announces the outcome in the
* #send-status live region (PLAN §7.4 never-stale). */
async function regenerateStaleChat() {
if (staleRegenBtn?.disabled) return; // one regenerate at a time (double-click guard)
staleRegenBtn.disabled = true;
try {
// Phase-49 targeting: the LAST brain bubble's rendered wrap. When a
// guard no-ops the redo (no brain bubble — the no-brain-record state
// that removed the button at reveal; in-flight turn; superseded
// wrap), retryLastTurn returns nothing and there is nothing to
// await or persist.
const turn = lastBrainWrap ? retryLastTurn(lastBrainWrap) : undefined;
if (!turn) return;
await turn; // the turn's completion — runTurn settles to idle always
// A regenerate that errored mid-stream (the error banner is up) leaves
// the linked row untouched — the row stays stale, the banner stays.
if (banner.classList.contains("is-error")) return;
// Persist the linked row through the SAME upsert as Save: PUT (the
// server re-stamps sources_version — the row is fresh again); a 404
// (deleted from History meanwhile) unlinks and recreates.
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
let res;
if (currentChatId) {
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
if (res.status === 404) {
// Stale link: the row is gone (deleted from History) — unlink
// and retry as a create, so the save never silently dies.
currentChatId = null;
res = await fetch("/api/chats", { method: "POST", headers, body });
}
} else {
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
showErrorBanner(
"Couldn't save the regenerated answer — check you're still signed in and try again."
);
return;
}
if (res.status === 201) {
const created = await res.json();
currentChatId = String(created.id); // the recreate: link the new row
}
staleBanner.hidden = true; // fresh row — the banner is done
sendStatus.textContent = "Regenerated — the answer now reflects the current sources.";
} catch {
showErrorBanner("Couldn't save the regenerated answer — is the app reachable?");
} finally {
if (staleRegenBtn) staleRegenBtn.disabled = false; // released on EVERY outcome
}
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
@@ -1319,6 +1431,7 @@ function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
currentChatId = null; // phase 50: a new conversation is unlinked until saved
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
clearStoredConversation();
removeTyping();
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
@@ -1377,7 +1490,12 @@ function stopTurn() {
* no scroll (phase 42: the fresh bubble lands where the old one was).
* Guards: inert while a turn is in flight (one turn at a time), and the
* click's wrap must still be the last brain bubble's rendered wrap — a
* stale click on a superseded bubble is harmless by construction. */
* stale click on a superseded bubble is harmless by construction.
* Phase 53 (task 05): RETURNS the runTurn promise when the redo runs
* (undefined when a guard no-ops it) — the stale banner's Regenerate
* path awaits the turn's completion to know when to persist the linked
* row. The existing Retry click handler ignores the return value, so
* phase-49 behavior is unchanged. */
function retryLastTurn(wrap) {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
@@ -1403,7 +1521,9 @@ function retryLastTurn(wrap) {
lastBrainWrap = null;
// Re-ask without re-adding: the reask turn skips the user append and
// persistence save point 1 (the question is already in both).
void runTurn(text, { reask: true });
// Phase 53: the promise is returned (the Regenerate await above);
// runTurn never rejects — a failure surfaces as the error banner.
return runTurn(text, { reask: true });
}
async function handleSend(e) {
@@ -1686,6 +1806,10 @@ saveBtn?.addEventListener("click", saveCurrentChat);
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
shareBtn?.addEventListener("click", shareCurrentChat);
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
* is inert unless the banner is revealed — which only happens on the
* /?chat=<id> boot path (admin, task-50 contract). */
staleRegenBtn?.addEventListener("click", regenerateStaleChat);
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
+28
View File
@@ -13,6 +13,12 @@
* into the saved conversation through ?chat= (task 03);
* • Messages — the row's message_count;
* • Updated — locale date+time, the full ISO in the title attribute;
* • Stale — phase 53 (task 04): the READ-ONLY staleness marker,
* rendered from the row's `stale` flag (the server computes it —
* the client never does staleness math): a rose "Stale" pill when
* the row was saved before the last KB-changing sync, an em-dash
* when fresh. The Regenerate action is NOT here — it lives on the
* chat-page banner (task 05); opening the row is the action;
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
* row's share state, rendered from the list's OWN share_url (the
* GET /api/chats endpoint populates it — no second fetch per row).
@@ -105,6 +111,28 @@ function makeRow(chat) {
updatedTd.textContent = fmtDate(chat.updated_at);
tr.appendChild(updatedTd);
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
// the READ-ONLY staleness marker. `chat.stale` is computed server-
// side (task 03), so this branches on the flag, never on versions.
// Stale rows get the rose pill (the exact hover copy points at the
// Regenerate action on the chat page, task 05); fresh rows get a
// plain em-dash. The <td> carries its own aria-label in BOTH states
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
const staleTd = document.createElement("td");
staleTd.className = "history-stale-cell";
if (chat.stale) {
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
pill.textContent = "Stale";
staleTd.appendChild(pill);
} else {
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
}
tr.appendChild(staleTd);
// Phase 51: the Share cell (between Updated and Actions) — the
// three-state share control (unshared / shared / confirming-unshare).
const shareTd = document.createElement("td");
+61
View File
@@ -1267,6 +1267,42 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
/* Phase 53 (task 05): the stale-saved-chat banner — the .kb-banner
FAMILY (the section carries both classes: same flex row, accent
tokens — accent-ink on accent-bg 9.5:1 — 18px glyph), but the
leading mark is the REDO glyph, distinct from the warning triangle.
It sits directly after #kb-banner in .chat-shell, so when both are
visible the empty-KB banner keeps the top slot and the stale banner
stacks directly below (the flex column's gap spaces them). The text
takes the row; the Regenerate pill right-aligns (margin-left: auto)
and the row wraps only when it must. The pill is the EXACT
brand-pill family of Save/Share: solid --brand, --bg text (5.2:1,
AA), borderless, 999px radius, ≥44px target, hover lightens the
brand fill; the 16px redo glyph is the phase-49 Retry asset. The
≤640px block below makes the pill a full-width row. */
.stale-banner { flex-wrap: wrap; }
.stale-banner > span { flex: 1 1 auto; }
.stale-regenerate {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.stale-regenerate:hover { background: #f55a72; color: var(--bg); }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
/* ---------- Login page (phase 16) ---------- */
/* Centered card in the standard frame: one admin, one password. */
@@ -1930,6 +1966,27 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* Updated: locale date+time (ink-soft), the full ISO in the title
attribute (history.js). */
.history-updated-cell { color: var(--ink-soft); white-space: nowrap; }
/* Stale marker (phase 53, task 04): the READ-ONLY staleness badge on
rows saved before the last KB-changing sync (the Regenerate action
lives on the chat-page banner — the marker is a pill, never a
control). The rose family, i.e. the Stop-treatment tokens, so
"stale" reads in the same visual language as the in-flight control:
err-ink on err-bg ≈9.3:1 (≥4.5:1 on --surface too), err-line
border — theme tokens, AA in the palette as a whole. Fresh rows'
em-dash rides the cell's ink-soft (5.1:1 on --surface). */
.history-stale-cell { color: var(--ink-soft); white-space: nowrap; }
.stale-pill {
display: inline-block;
padding: 0.15rem 0.55rem;
border: 1px solid var(--err-line);
border-radius: 999px;
background: var(--err-bg);
color: var(--err-ink);
font-size: 0.75rem;
font-weight: 700;
line-height: 1.45;
white-space: nowrap;
}
/* Actions: the Delete ghost button (the tuning row-action language)
+ the inline two-step confirm pair (phase 50 task 04). */
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
@@ -2650,6 +2707,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.chat-shell .save-chat-btn svg { display: none; }
.chat-shell .share-chat-label { display: inline; }
.chat-shell .share-chat-btn svg { display: none; }
/* Phase 53: the stale banner's row wraps at phone width (message
above, action below) — the pill takes a full-width comfortable
row instead of squeezing into the text. */
.stale-regenerate { margin-left: 0; width: 100%; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.3rem; }
+6 -2
View File
@@ -135,7 +135,10 @@
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages |
Updated | Share (phase 51: Create link / Copy / Unshare —
Updated | Stale (phase 53: the READ-ONLY staleness marker —
the rose pill when the row predates the last KB-changing
sync; the Regenerate action lives on the chat-page banner,
task 05) | Share (phase 51: Create link / Copy / Unshare —
the row's share_url comes from GET /api/chats itself, no
second fetch) | Actions (Delete, inline two-step confirm).
history.js fills #history-tbody; #history-empty-row ships
@@ -150,13 +153,14 @@
<th scope="col">Title</th>
<th scope="col">Messages</th>
<th scope="col">Updated</th>
<th scope="col">Stale</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden>
<td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
<td colspan="6">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
</tr>
</tbody>
</table>
+23
View File
@@ -89,6 +89,29 @@
<span id="kb-banner-text"></span>
</div>
<!-- Phase 53 (task 05): the stale-saved-chat banner. The /?chat=<id>
boot load reveals it ONLY when the fetched row reports
stale: true (the server computes it — the row's sources stamp
is behind the current generation, task 03; the client never
does staleness math). Regenerate (the exact brand-pill family
of the Save/Share pair; the redo glyph is the phase-49 Retry
asset) re-asks the last question against the new index via
retryLastTurn and re-saves the linked row (the server
re-stamps sources_version → stale: false), clearing the
banner. A stale chat with no brain answer is revealed
text-only — app.js removes the button, so retryLastTurn is
never called. Stacks directly below #kb-banner when both are
visible (kb-banner keeps the top slot; the .chat-shell flex
gap spaces them). -->
<section class="kb-banner stale-banner" id="stale-banner" role="status" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span>The sources have been updated since this chat was saved.</span>
<button type="button" class="stale-regenerate" id="stale-regenerate">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span>Regenerate</span>
</button>
</section>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
+48 -8
View File
@@ -45,6 +45,16 @@ debug runs never burn a ``lite`` call, and a ``lite`` failure only
reports ``overview=failed`` on the summary line: the import's exit code
is about files, and the previous outline stays (an old outline is better
than none).
A run that **changed** the knowledge base (added + updated + pruned > 0
— phase 53, task 02) also advances the single-row ``sources_meta``
version exactly once (``sources_version=<n>`` on the summary line): the
generation saved chats are stamped against, so a sync can no longer
silently invalidate a stored answer. The gate is deliberately broader
than the overview's — a pruned document can invalidate a saved answer
that cited it — and ``--limit`` debug runs (an incomplete walk is
debug-only, mirroring the ``--limit`` overview skip) and unchanged
re-runs never bump (the line carries ``sources_version=skipped``).
"""
from __future__ import annotations
@@ -64,6 +74,7 @@ from app.rag.git_sources import effective_sources
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import regenerate_overview
from app.rag.sources_meta import bump_sources_version
from scripts.git_sync import GitSyncError, clone_or_pull
logger = logging.getLogger("scripts.import_docs")
@@ -211,8 +222,9 @@ def main(argv: list[str] | None = None) -> int:
llm = LLMClient()
async def _run() -> tuple[ImportSummary, str]:
"""Import, then (change-gated) refresh the stored KB overview.
async def _run() -> tuple[ImportSummary, str, str]:
"""Import, then (change-gated) advance the sources version and
refresh the stored KB overview.
One event loop, one ``LLMClient`` (phase 31, task 04): the
outline that every chat prompt injects as ``<knowledge_base>`` is
@@ -224,31 +236,59 @@ def main(argv: list[str] | None = None) -> int:
unchanged re-imports never burn a ``lite`` call, and a ``lite``
failure only flips the status token (``failed``) — the import's
exit code is unchanged.
The sources version (phase 53, task 02) advances exactly once
per run that changed the KB — change-gated on
``added + updated + pruned > 0`` (a pruned document can
invalidate a saved answer that cited it, so the gate is
deliberately broader than the overview's). ``--limit`` debug
runs and unchanged re-runs never bump. The returned token is
the new version, or ``"skipped"``.
"""
summary = await import_sources(sources, llm, prune=args.prune, limit=args.limit)
if args.limit is not None:
# An incomplete walk is debug-only — it must never advance
# the generation (mirrors the --limit overview skip below).
sources_version = "skipped"
elif summary.added + summary.updated + summary.pruned > 0:
# The KB changed — advance the saved-chat invalidation
# marker exactly once, in its own short session (the
# best-effort overview below runs in a separate one, so a
# failed outline never rolls the bump back).
session = SessionLocal()
try:
new_version = bump_sources_version(session)
session.commit()
finally:
session.close()
sources_version = str(new_version)
logger.info("sources: version bumped to %d", new_version)
else:
sources_version = "skipped"
logger.info("sources: version bump skipped (KB unchanged)")
if args.limit is not None:
logger.info("overview: skipped (--limit)")
return summary, "skipped"
return summary, "skipped", sources_version
if summary.added + summary.updated == 0:
if summary.files == 0:
logger.info("overview: skipped (nothing imported)")
return summary, "skipped"
return summary, "skipped", sources_version
if _overview_row_exists():
logger.info("overview: skipped (KB unchanged)")
return summary, "skipped"
return summary, "skipped", sources_version
# No outline yet after an unchanged re-import (e.g. the first
# run after migration 0005) — fall through and generate one.
ok = await regenerate_overview(llm)
return summary, "updated" if ok else "failed"
return summary, "updated" if ok else "failed", sources_version
summary, overview_status = asyncio.run(_run())
summary, overview_status, sources_version = asyncio.run(_run())
print(
f"import_docs: files={summary.files} added={summary.added} "
f"updated={summary.updated} unchanged={summary.unchanged} "
f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} "
f"embed_batches={summary.embed_batches} summaries={summary.summaries} "
f"summary_errors={summary.summary_errors} formats={summary.format_counts()} "
f"overview={overview_status}"
f"overview={overview_status} sources_version={sources_version}"
)
# Non-zero if any file failed, so cron/CI notice — the rest of the KB
# was imported and the failed files are retried on the next run. The
+544
View File
@@ -0,0 +1,544 @@
"""Phase 53 E2E (Playwright): invalidate saved chats on sources sync.
TODO.md L4 (owner 2026-08-30): "Make sure the saved chats are invalidated
if the docs are synced, that way it generates a new answer with new data".
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov
The full user-visible invalidation loop under test:
* **Save (fresh)** — as admin: ask (the mock answers deterministically),
Save via the chat-page pill; ``GET /api/chats`` (admin cookie) reports
the row with ``stale: false``; opening the FRESH row at ``/?chat=<id>``
shows NO banner;
* **KB change** — the test process (which shares the app's environment)
bumps the ``sources_meta`` seed row through ``bump_sources_version``
over a short ``SessionLocal()`` — the exact helper BOTH real sync
paths (the Sync button, ``scripts/import_docs``) call change-gated.
The bump GATES are pinned by this phase's integration tests (task 02)
and the Sync button's end-to-end clone/import path by
``test_sync_button.py``; the E2E proves the user-visible loop, not
git plumbing (the phase's recorded ASSUMPTION);
* **Stale surfaced** — ``/history.html``: the row now carries the rose
Stale pill (``aria-label``d for screen readers); ``GET /api/chats``
and ``GET /api/chats/<id>`` carry ``stale: true`` (computed
server-side — the client never computes staleness);
* **Regenerate** — opening the row (``/?chat=<id>``, the same URL the
History table links) reveals ``#stale-banner`` with the Regenerate
button; clicking it re-streams the last answer IN PLACE against the
new index (the phase-49 redo-in-place: the old bubble leaves the DOM,
the question is not duplicated), and the handler then auto re-saves
the linked row — the server re-stamps ``sources_version`` → the
banner clears, ``GET /api/chats/<id>`` reports ``stale: false`` with
the last brain message being the fresh answer, and the History pill
is gone;
* **Guard** — a stale row whose conversation has NO brain record
(user-only) reveals the banner text WITHOUT the Regenerate button
(``retryLastTurn`` has nothing to redo);
* **Anonymous** — sharing the (now fresh) chat and opening
``/shared/<token>`` without a session renders the phase-51 snapshot
with NO staleness surface — neither in the DOM nor on the public
``SharedChatOut`` wire (the snapshot is frozen by design).
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test here uses a DISTINCTIVE question text (its
auto-title is therefore unique), never asserts on absolute row counts,
and deletes the rows it creates in a ``finally`` (admin cookie). The KB
tables are truncated + re-seeded the house way (deterministic mock
embeddings); ``saved_chats`` and ``sources_meta`` are never touched by
the reset — the version is monotonic by design, and rows stamped
against an older generation are simply stale (that is the point).
Determinism: the mock quotes the asked question into its grounded
answer (ending in the ``Deterministic mock answer for E2E`` marker),
so the regenerated answer is textually identical to the stale one —
OLD-vs-fresh bubble identity is proved the phase-49 way, with a
test-only ``data-retry-marker`` attribute set on the old wrap before
the click.
"""
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import SavedChat
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.sources_meta import bump_sources_version, current_sources_version
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The banner's line (frontend/index.html, task 05).
BANNER_TEXT = "The sources have been updated since this chat was saved."
#: The live-region outcome of a successful Regenerate (app.js, task 05).
REGEN_STATUS = "Regenerated — the answer now reflects the current sources."
#: The fresh row's Stale cell (history.js, task 04).
FRESH_STALE_CELL = "—"
#: The stale row's Stale cell aria-label (history.js, task 04).
STALE_CELL_ARIA = "Stale — sources have changed since this chat was saved"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
and ``sources_meta`` are deliberately NOT touched: rows persist
across suites (every test cleans up after itself) and the version
is monotonic (the invalidation marker)."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes")
)
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _bump_sources_version() -> int:
"""The test-only stand-in for a KB-changing sync (task 02): the SAME
``bump_sources_version`` helper both real sync paths call
change-gated, over a short session (bump flushes, the caller
commits). The bump GATES — changed/unchanged/`--limit`/failed — are
pinned by this phase's integration tests, not by the E2E."""
with SessionLocal() as db:
v = bump_sources_version(db)
db.commit()
return v
def _current_version() -> int:
with SessionLocal() as db:
return current_sources_version(db)
def _row_stamp(chat_id: str) -> int:
"""The saved row's ``sources_version`` stamp (helper read — the
same pattern as the phase's integration tests)."""
with SessionLocal() as db:
row = db.get(SavedChat, uuid.UUID(chat_id))
assert row is not None
return row.sources_version
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _save(page: Page) -> None:
"""Press Save and wait for the live-region confirmation (the
never-stale contract: the status line is the success feedback)."""
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _chat(app_url: str, cookies: dict[str, str], chat_id: str) -> dict[str, Any]:
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(
rows: list[dict[str, Any]], title: str
) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _history_row(page: Page, chat_id: str) -> Any:
"""The History table row for this chat (located through the Open
link — ``/?chat=<id>`` — the same URL the table links)."""
return page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
def _assert_no_error_banner(page: Page) -> None:
"""Regenerate settles through the normal done path — never the red
role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_class("is-error")
def _tag_last_brain_wrap(page: Page, marker: str) -> None:
"""Tag the rendered wrap of the LAST brain bubble (settled state —
no typing indicator present) so the test can prove the OLD element
leaves the DOM: the mock answers are byte-stable, so the redo of
the same question is textually indistinguishable from the original."""
page.evaluate(
"""(marker) => {
const wraps = document.querySelectorAll("#messages > .msg.brain");
wraps[wraps.length - 1].setAttribute("data-retry-marker", marker);
}""",
marker,
)
def _regenerate_in_place(page: Page, marker: str) -> None:
"""Click the banner's Regenerate and wait for the FULL phase-49
redo-in-place + auto re-save to settle: the turn goes in-flight
(the button IS Stop), the old bubble leaves the DOM before the
first fresh token, the fresh answer lands, and the handler's
post-turn PUT (the server re-stamps ``sources_version``) clears the
banner and lands the outcome on the live region."""
_tag_last_brain_wrap(page, marker)
regen = page.locator("#stale-regenerate")
expect(regen).to_be_enabled()
regen.click()
# In flight: the redo owns the Send/Stop control…
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
# …and the double-click guard holds (one regenerate at a time).
expect(regen).to_be_disabled()
# Redo in place: the OLD wrap is already gone (the phase-49
# contract — removal precedes the rerun).
expect(page.locator(f"[data-retry-marker='{marker}']")).to_have_count(0)
# The fresh answer streams into its place and the turn settles.
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# Post-turn: the linked row was re-saved (the server re-stamped
# it), so the banner cleared and the outcome is announced.
expect(page.locator("#stale-banner")).to_be_hidden(timeout=15_000)
expect(page.locator("#send-status")).to_have_text(REGEN_STATUS)
# ---------------------------------------------------------------------------
# 1. The full loop: save (fresh) → KB change → stale surfaced → Regenerate
# → fresh again (UI + API agree at every step)
# ---------------------------------------------------------------------------
def test_full_invalidation_loop(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (stale-loop)"
_ask(page, q)
# --- Save (fresh): the API agrees, and a fresh open shows NO banner.
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the saved chat row must exist"
assert row["message_count"] == 2
assert row["stale"] is False, "a just-saved chat is fresh, not stale"
chat_id: str = row["id"]
# The DB agrees: Save stamped the row with the CURRENT generation
# (helper read — the integration suite pins the same contract).
assert _row_stamp(chat_id) == _current_version(), (
"Save must stamp sources_version with the current generation"
)
try:
# A fresh row opened at /?chat=<id> shows NO stale banner.
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
expect(page.locator(ANSWER)).to_have_count(1)
expect(page.locator("#stale-banner")).to_be_hidden()
expect(page.locator("#stale-regenerate")).to_be_hidden()
# --- KB change: the version bumps (the test-only stand-in for a
# KB-changing sync — see the module docstring / task ASSUMPTION).
bumped = _bump_sources_version()
assert bumped > 0
# The API agrees the row is stale NOW (server-computed — both
# the detail and the list shapes carry the flag):
detail = _chat(app_url, cookies, chat_id)
assert detail["stale"] is True
mine = _find_row(_chats(app_url, cookies), _auto_title(q))
assert mine is not None and mine["stale"] is True
# --- History: the row carries the rose Stale pill (and its
# aria-label, so the marker is conveyed without the visual).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
pill = tr.locator(".stale-pill")
expect(pill).to_have_count(1)
expect(pill).to_have_text("Stale")
expect(tr.locator(".history-stale-cell")).to_have_attribute(
"aria-label", STALE_CELL_ARIA
)
# --- Open the row (the SAME URL the History table links): the
# banner reveals, with the Regenerate button.
tr.locator("a.history-title-link").click()
banner = page.locator("#stale-banner")
expect(banner).to_be_visible(timeout=15_000)
expect(banner).to_have_attribute("role", "status")
expect(banner).to_contain_text(BANNER_TEXT)
expect(page.locator("#stale-regenerate")).to_have_text("Regenerate")
# The conversation restored in full (one question, one answer).
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(ANSWER)).to_have_count(1)
# --- Regenerate: the fresh answer streams in place against the
# new index, and the handler re-saves the linked row.
_regenerate_in_place(page, "stale-old")
# Redo mechanics: the question was NOT duplicated, the fresh
# answer stands alone in the last bubble (it quotes the
# question — the mock is deterministic).
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(ANSWER)).to_have_count(1)
expect(page.locator(ANSWER).last).to_contain_text(q)
expect(page.locator("#stale-regenerate")).to_be_enabled()
# --- The API agrees the row is fresh again, with the fresh
# answer as its last brain message (the re-save re-stamped the
# row — the DB agrees).
detail = _chat(app_url, cookies, chat_id)
assert detail["stale"] is False, "the re-saved row must be fresh"
msgs = detail["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"], (
"the re-save must not duplicate the question"
)
assert msgs[0]["text"] == q
assert MOCK_ANSWER_MARKER in msgs[-1]["text"]
assert q in msgs[-1]["text"], "the fresh answer quotes the re-asked question"
assert _row_stamp(chat_id) == _current_version(), (
"the Regenerate re-save must re-stamp sources_version"
)
# --- History: the Stale pill is gone (the em-dash returns).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
expect(tr.locator(".stale-pill")).to_have_count(0)
expect(tr.locator(".history-stale-cell")).to_have_text(FRESH_STALE_CELL)
finally:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 2. Guard: a stale chat with NO brain record reveals the banner text
# WITHOUT the Regenerate button (nothing to regenerate)
# ---------------------------------------------------------------------------
def test_stale_chat_without_brain_answer_is_text_only(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
cookies = _admin_cookies(page)
# A user-only saved conversation — the same wire shape the Save pill
# posts (auto-title from the first user message).
q = "How is my Kubernetes cluster set up? (stale-nobrain)"
r = httpx.post(
f"{app_url}/api/chats",
timeout=10,
cookies=cookies,
json={"messages": [{"who": "user", "text": q}]},
)
assert r.status_code == 201, r.text
chat_id = r.json()["id"]
assert r.json()["stale"] is False # freshly stamped on create
try:
_bump_sources_version()
assert _chat(app_url, cookies, chat_id)["stale"] is True
# Opening the stale user-only row: the banner reveals…
page.goto(app_url + f"/?chat={chat_id}")
banner = page.locator("#stale-banner")
expect(banner).to_be_visible(timeout=15_000)
expect(banner).to_contain_text(BANNER_TEXT)
# …but the Regenerate button is REMOVED — with no brain record
# there is nothing to regenerate (retryLastTurn is never called).
expect(page.locator("#stale-regenerate")).to_have_count(0)
# The conversation restored: the user question, no brain bubble.
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
expect(page.locator(ANSWER)).to_have_count(0)
assert not js_errors, f"the text-only banner must not throw: {js_errors}"
finally:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 3. Anonymous: the shared (now fresh) chat renders at /shared/<token>
# with NO staleness surface — neither in the DOM nor on the public
# SharedChatOut wire (phase 51 unchanged)
# ---------------------------------------------------------------------------
def test_anonymous_shared_snapshot_has_no_staleness_surface(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (stale-share)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# Make the chat stale, then bring it back CURRENT through the
# full Regenerate loop — the chat shared below is fresh.
_bump_sources_version()
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator("#stale-banner")).to_be_visible(timeout=15_000)
_regenerate_in_place(page, "stale-share-old")
assert _chat(app_url, cookies, chat_id)["stale"] is False
# Share from the History row's Share column (Create link → the
# cell re-renders to Copy + Unshare).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
create = tr.locator("button.history-share-create")
expect(create).to_be_visible(timeout=15_000)
create.click()
expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000)
share_url = _chat(app_url, cookies, chat_id).get("share_url")
assert share_url is not None, "the Create link must have shared the row"
token = share_url.removeprefix("/shared/")
# --- The FRESH anonymous context (no session): the phase-51
# snapshot renders in full…
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
# …with NO staleness surface anywhere: none of the phase-53
# elements exist on the shared page, and the banner's line never
# appears (the snapshot is frozen by design — phase 51). (The
# check is on the UI strings, not the bare word: the question
# text is quoted into the mock's answer and is data, not a
# staleness surface.)
stale_selectors = (
"#stale-banner, #stale-regenerate, "
".stale-banner, .stale-pill, .stale-regenerate"
)
expect(anon.locator(stale_selectors)).to_have_count(0)
body_text = anon.locator("body").inner_text().lower()
assert "sources have been updated since this chat was saved" not in body_text, (
"the shared snapshot must carry no staleness surface"
)
anon_ctx.close()
anon_ctx = None
# …and the public wire agrees: the anonymous read carries
# title + messages ONLY (no stale flag, no staleness surface).
r = httpx.get(f"{app_url}/api/shared/{token}", timeout=10)
assert r.status_code == 200
body = r.json()
assert set(body) == {"title", "messages"}, (
f"the public snapshot must stay minimal: {set(body)}"
)
assert "stale" not in body
assert body["title"] == _auto_title(q)
assert MOCK_ANSWER_MARKER in body["messages"][-1]["text"]
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
+191 -4
View File
@@ -10,7 +10,11 @@ fallback), the list order (``updated_at desc, id desc``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
``sources``/``thinking``/``tools``/``stopped`` survives losslessly),
the PUT upsert semantics (replacement + title-keep + title-set +
``updated_at`` bump), and the delete 404/204.
``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the
sources-version stamp + ``stale`` flag: create and re-Save stamp the
row's ``sources_version``, list/detail expose ``stale`` (true iff the
stamp is behind the current generation — computed server-side), and
share/unshare stay version-immune.
Requires: podman compose up -d db
"""
@@ -31,7 +35,8 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.main import app as fastapi_app
from app.models import SavedChat
from app.models import SavedChat, SourcesMeta
from app.rag.sources_meta import bump_sources_version
FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes"
@@ -62,8 +67,16 @@ FULL_BRAIN: dict[str, Any] = {
"stopped": False,
}
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
OUT_KEYS = {
"id",
"title",
"created_at",
"updated_at",
"message_count",
"messages",
"stale", # phase 53: server-computed staleness flag
}
ROW_KEYS = {"id", "title", "updated_at", "message_count", "stale"}
@pytest.fixture(autouse=True)
@@ -76,6 +89,45 @@ def clean_chats(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def seeded_sources_meta(db: Session) -> Iterator[None]:
"""The ``sources_meta`` counter (phase 53) is global state: reset
to the migration-0010 seed (id 1, version 0 — "the pre-counter
KB") around every test, so each test starts from a known
generation and the dev DB is left exactly as the migration left
it."""
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
yield
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
def _bump(db: Session) -> int:
"""One simulated KB-changing sync: a single committed bump
(task 02's change-gate lives in the sync paths themselves; the API
tests only need the counter's contract — caller commits).
"""
version = bump_sources_version(db)
db.commit() # the helper only flushes
return version
def _stored_sources_version(db: Session, chat_id: str) -> int:
"""The row's ``sources_version`` via raw SQL — deliberately
bypassing the session's identity map, because the API's commits
land in the app's own sessions (a cached ORM object could be
stale)."""
return int(
db.execute(
text("SELECT sources_version FROM saved_chats WHERE id = :id"),
{"id": chat_id},
).scalar_one()
)
def _user(text: str) -> dict[str, Any]:
return {"who": "user", "text": text}
@@ -716,6 +768,141 @@ def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> N
assert set(got2) == OUT_KEYS
# ---------- sources-version stamp + stale flag (phase 53, task 03) ----------
def test_create_stamps_current_sources_version(
admin_client: TestClient, db: Session
) -> None:
"""``POST`` stamps the PENDING row with the current generation —
it ships in the same INSERT (the ``share_token`` precedent), and
the 201 body reports ``stale: false`` (a fresh save is by
definition current)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert created["stale"] is False, "a fresh save is never stale"
assert _stored_sources_version(db, created["id"]) == 0 # seed generation
# After a KB-changing sync (one bump), the NEXT save stamps the
# new generation and is still fresh.
assert _bump(db) == 1
created2 = admin_client.post(
"/api/chats", json={"messages": [_user("another question")]}
).json()
assert _stored_sources_version(db, created2["id"]) == 1
assert created2["stale"] is False
def test_pre_counter_row_goes_stale_on_the_first_bump(
admin_client: TestClient, db: Session
) -> None:
"""Recorded assumption: pre-existing rows (saved before the counter
existed) stamp 0 — "the pre-counter KB" — and go stale on the
first bump (0 < 1). A row inserted directly with the column's
server default mirrors such a legacy row."""
db.add(SavedChat(title="legacy", messages=[_user("old question")]))
db.commit() # no sources_version supplied → server default 0
listing = admin_client.get("/api/chats").json()["chats"][0]
assert listing["stale"] is False, "0 == 0: current at the pre-counter KB"
assert _bump(db) == 1
listing = admin_client.get("/api/chats").json()["chats"][0]
assert listing["stale"] is True, "0 < 1: the first bump stale-s it"
def test_list_and_detail_report_stale_after_bump(admin_client: TestClient, db: Session) -> None:
"""The staleness flag is computed server-side in BOTH admin read
shapes: after a bump, the list row and the detail payload of a
row saved at the older generation report ``stale: true`` (and a
second bump — still behind — stays stale)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert created["stale"] is False
assert _bump(db) == 1
row = admin_client.get("/api/chats").json()["chats"][0]
assert row["stale"] is True
assert set(row) == ROW_KEYS # stale sits in the standard row shape
detail = admin_client.get(f"/api/chats/{created['id']}").json()
assert detail["stale"] is True
assert set(detail) == OUT_KEYS
assert _bump(db) == 2 # still behind (stamp 0 < 2) → still stale
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
def test_resave_restamps_and_clears_stale(admin_client: TestClient, db: Session) -> None:
"""A Re-Save (``PUT``) re-stamps the row to the CURRENT generation
unconditionally — the owner is affirming this content against the
current KB — so the 200 body reports ``stale: false`` again and
the stored stamp advances (the manual escape hatch for a
false-positive stale row)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert _bump(db) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
r = admin_client.put(
f"/api/chats/{created['id']}", json={"messages": _simple_conversation()}
)
assert r.status_code == 200
assert r.json()["stale"] is False, "the re-Save affirms against generation 1"
assert _stored_sources_version(db, created["id"]) == 1
# A later bump stale-s it again — the stamp is a point in time, not
# a sticky flag.
assert _bump(db) == 2
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
def test_share_and_unshare_leave_sources_version_untouched(
admin_client: TestClient, db: Session
) -> None:
"""Share/unshare write ONLY ``share_token`` (raw SQL, the
phase-51 contract) — the version stamp, like ``updated_at``, is
immune: neither action can (un-)stale a chat, and staleness stays
a pure function of the saved generation vs the current one."""
assert _bump(db) == 1 # a non-zero stamp makes the assert observable
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert _stored_sources_version(db, created["id"]) == 1
_share(admin_client, created["id"])
assert _stored_sources_version(db, created["id"]) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
assert _stored_sources_version(db, created["id"]) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
def test_public_read_snapshot_has_no_stale_surface(
admin_client: TestClient, db: Session,
) -> None:
"""``/api/shared/<token>`` is a FROZEN snapshot by design (phase
51): even after a bump, the anonymous body keeps exactly its
title+messages key set — no ``stale`` flag, no staleness surface
(an owner who regenerates can re-share afterwards)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
assert _bump(db) == 1 # the saved chat is now stale (admin surface)
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{created['share_url']}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS # no stale key — frozen snapshot
assert "stale" not in body
# ---------- the /shared/<token> page route (phase 51, task 01) ----------
+33 -3
View File
@@ -4,7 +4,9 @@ kind).
Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull``
(no real git, no network) and a recording fake ``import_sources`` (no
real DB), covering:
real DB), covering: the phase-53 version bump is stubbed in the
``main()`` tests the same way (the ``sources_version=`` summary token
is asserted against the canned value).
- Effective sources set (phase 35: the shared resolver — stubbed here,
keeping this file's no-real-DB style) → each git URL is cloned/pulled
@@ -83,6 +85,20 @@ def _fake_clone_factory() -> tuple[list[tuple[str, Path]], object]:
return calls, fake_clone_or_pull
def _stub_bump(monkeypatch: pytest.MonkeyPatch) -> list[None]:
"""Stub the phase-53 version bump (this file keeps its no-real-DB
style for the counter — the fake import already avoids the KB
tables). Returns the call record; the canned new version is 1."""
bumps: list[None] = []
def fake_bump(session: object) -> int:
bumps.append(None)
return 1
monkeypatch.setattr(import_docs, "bump_sources_version", fake_bump)
return bumps
# --- repo_name -------------------------------------------------------------
@@ -210,6 +226,7 @@ def test_main_git_sources_clone_then_import(
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
fake_import = FakeImportSources()
monkeypatch.setattr(import_docs, "import_sources", fake_import)
bumps = _stub_bump(monkeypatch)
rc = import_docs.main([])
@@ -227,11 +244,19 @@ def test_main_git_sources_clone_then_import(
for dest in (tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"):
assert (dest / "notes.md").is_file()
# The final summary print reflects the import (added > 0).
assert "added=1" in capsys.readouterr().out
out = capsys.readouterr().out
assert "added=1" in out
# Phase 53: a KB-changing run bumps the sources version exactly
# once and reports it (stubbed — this file keeps its no-real-DB
# style for the counter, like the fake import above).
assert len(bumps) == 1
assert "sources_version=1" in out
def test_main_cli_source_still_imports_manual_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
manual = tmp_path / "manual"
manual.mkdir()
@@ -243,6 +268,7 @@ def test_main_cli_source_still_imports_manual_dir(
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
fake_import = FakeImportSources()
monkeypatch.setattr(import_docs, "import_sources", fake_import)
bumps = _stub_bump(monkeypatch)
rc = import_docs.main(["--source", str(manual)])
@@ -250,6 +276,10 @@ def test_main_cli_source_still_imports_manual_dir(
assert calls == []
assert fake_import.calls[0]["sources"] == [manual]
assert fake_import.calls[0]["prune"] is False
# Phase 53: a manual --source run that changes the KB bumps exactly
# once (the CLI is the other canonical sync path).
assert len(bumps) == 1
assert "sources_version=1" in capsys.readouterr().out
def test_resolve_sources_mixed_git_and_local(
+84 -9
View File
@@ -12,6 +12,17 @@ covering the change-gated overview trigger:
``overview=failed``, the previous row untouched;
- a ``--limit`` debug run with changes → ``overview=skipped``;
- an empty source run with no row → no row created, ``overview=skipped``.
Phase 53 (task 02): the sources-version bump sits alongside the
overview gate — a KB-changing run bumps ``sources_meta`` exactly once
(``sources_version=<n>`` on the summary line), including a **prune-only**
run: the invalidation gate ``added + updated + pruned > 0`` is
deliberately broader than the overview's (a pruned doc can invalidate a
saved answer that cited it, while the outline stays). ``--limit`` runs
and unchanged re-runs never bump (``sources_version=skipped``), and a
failed ``lite`` never rolls the bump back. The counter is pinned to the
migration-0010 seed (0) around every test by
:func:`_reset_sources_version`.
"""
from __future__ import annotations
@@ -24,8 +35,10 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import SessionLocal, db_available
from app.models import KbOverview
from app.rag.llm import LLMError
from app.rag.sources_meta import current_sources_version
from scripts import import_docs
from tests.fakes import FakeEmbedder
@@ -44,6 +57,12 @@ def _row(db: Session) -> KbOverview | None:
return db.get(KbOverview, 1)
def _version(db: Session) -> int:
"""The ``sources_meta`` generation (phase 53; freshly reloaded)."""
db.expire_all()
return current_sources_version(db)
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
@@ -86,6 +105,26 @@ def _clean_kb(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""Phase 53: the sources version counter is global mutable state —
pin it to the migration-0010 seed (0) around every test so the bump
assertions start from a known generation (own session: the CLI
bumps through its own short-lived ``SessionLocal``). Skips like the
``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
def test_changed_import_writes_overview_row(
db: Session,
src: Path,
@@ -110,7 +149,8 @@ def test_changed_import_writes_overview_row(
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert _version(db) == 1 # phase 53: a changed import bumps exactly once
# Exactly one lite call — the overview itself (markdown files never
# get a summary, so nothing else may touch ``chat``).
assert len(llm.chat_calls) == 1
@@ -137,7 +177,7 @@ def test_unchanged_reimport_does_not_call_lite(
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert len(llm.chat_calls) == 1
assert _row(db) is not None
@@ -145,10 +185,11 @@ def test_unchanged_reimport_does_not_call_lite(
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=2" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert len(llm.chat_calls) == 1 # no new lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
assert _version(db) == 1 # phase 53: an unchanged re-run never bumps
def test_lite_failure_is_fail_soft(
@@ -160,7 +201,7 @@ def test_lite_failure_is_fail_soft(
good = FakeEmbedder()
rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
previous = _row(db)
assert previous is not None
previous_content = previous.content
@@ -172,11 +213,14 @@ def test_lite_failure_is_fail_soft(
rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys)
assert rc == 0 # a failed outline must not fail the import
assert "updated=1" in out
assert out.rstrip().endswith("overview=failed")
assert out.rstrip().endswith("overview=failed sources_version=2")
assert len(bad.chat_calls) == 1 # the (failed) attempt was made
row = _row(db)
assert row is not None
assert row.content == previous_content # previous row untouched
# Phase 53: the bump commits independently of the best-effort
# overview — a failed ``lite`` never rolls the version back.
assert _version(db) == 2
def test_limit_run_skips_overview(
@@ -188,19 +232,20 @@ def test_limit_run_skips_overview(
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert len(llm.chat_calls) == 1
# An incomplete walk must not rewrite the outline (mirrors the
# --prune-with---limit guard).
# --prune-with---limit guard) — and must not advance the version.
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys)
assert rc == 0
assert "updated=1" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert len(llm.chat_calls) == 1 # --limit never burns a lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
assert _version(db) == 1 # phase 53: --limit debug runs never bump
def test_empty_source_without_row_creates_nothing(
@@ -217,6 +262,36 @@ def test_empty_source_without_row_creates_nothing(
assert rc == 0
assert "files=0" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
assert _row(db) is None # nothing created
assert _version(db) == 0 # nothing changed → nothing bumped
def test_prune_only_run_bumps_sources_version(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 53 (task 02): the invalidation gate is deliberately broader
than the overview's — a prune-only run (added + updated == 0,
pruned > 0) advances the version (a pruned document can invalidate
a saved answer that cited it) while the outline stays.
"""
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith("overview=updated sources_version=1")
assert _version(db) == 1
# Delete one file; a --prune run drops exactly it: no add/update,
# but pruned=1 → the version still bumps while the overview skips.
(src / "alpha.md").unlink()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--prune"], capsys)
assert rc == 0
assert "pruned=1" in out
assert out.rstrip().endswith("overview=skipped sources_version=2")
assert _version(db) == 2 # the prune-only change bumped exactly once
assert _row(db) is not None # the outline row is untouched
+260
View File
@@ -0,0 +1,260 @@
"""Integration: migration 0010 (sources_meta + saved_chats.sources_version).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0009.py`` (information_schema / pg catalog assertions
on the state the migration must leave). The tests target revision
``0010`` explicitly so later migrations cannot break them:
* upgrade 0009 → 0010 → the single-row ``sources_meta`` table exists
(``id`` Integer PK default 1, ``version`` Integer NOT NULL default 0,
``updated_at`` TIMESTAMPTZ NOT NULL default now()) with its **seed
row** (id 1, version 0), and ``saved_chats.sources_version`` is
Integer NOT NULL default 0 — a pre-0010 row comes back stamped 0
(the pre-counter KB, phase-53 locked decision 2);
* inserted rows round-trip the stamp (default and explicit);
* downgrade to 0009 → column + table gone (A13 — reversible), the rest
of ``saved_chats`` survives;
* upgrade back to 0010 → table, seed row, and column are all 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
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
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:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" 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 _seed_row(db: Session) -> tuple[int, int] | None:
"""The (id, version) of the ``sources_meta`` row with id 1."""
row = db.execute(
text("SELECT id, version FROM sources_meta WHERE id = 1")
).fetchone()
return tuple(row) if row is not None else None
def _insert(db: Session, version: int | None) -> uuid.UUID:
"""Insert one saved_chats row, optionally with an explicit stamp."""
if version is None:
sql = (
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
)
params: dict[str, Any] = {}
else:
sql = (
"INSERT INTO saved_chats (id, title, messages, sources_version)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :v)"
" RETURNING id"
)
params = {"v": version}
params.update(
{"t": "Mig 0010", "m": '[{"who": "user", "text": "How did I install gitlab?"}]'}
)
chat_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
db.commit()
return chat_id
def _legacy_insert(db: Session) -> uuid.UUID:
"""Insert one row WITHOUT the ``sources_version`` column — the only
possible shape at revision 0009 (the column does not exist yet)."""
chat_id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
),
{
"t": "Mig 0010",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
},
).scalar_one()
db.commit()
return chat_id
def _delete(db: Session, chat_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id})
db.commit()
def test_upgrade_to_0010_adds_sources_meta_and_stamp(
db: Session, alembic: Config
) -> None:
"""Upgrade 0009 → 0010: the seeded counter table and the NOT NULL
stamp column exist; a pre-0010 row comes back stamped 0 (the
pre-counter KB)."""
command.downgrade(alembic, "0009") # start from the pre-0010 state
assert _version(db) == "0009"
assert not _table_exists(db, "sources_meta"), "sources_meta must be absent at 0009"
assert _column(db, "saved_chats", "sources_version") is None, (
"sources_version must be absent at 0009"
)
# A pre-0010 row (no sources_version in the INSERT — the column does
# not exist at 0009): its data must survive the additive migration.
legacy = _legacy_insert(db)
try:
command.upgrade(alembic, "0010")
assert _version(db) == "0010", "alembic_version must be at 0010"
id_col = _column(db, "sources_meta", "id")
assert id_col is not None, "sources_meta.id is missing"
assert id_col[0] == "integer", "sources_meta.id must be INTEGER"
assert id_col[1] == "NO", "sources_meta.id must be NOT NULL (PK)"
assert id_col[2] == "1", "sources_meta.id must default to 1"
ver_col = _column(db, "sources_meta", "version")
assert ver_col is not None, "sources_meta.version is missing"
assert ver_col[0] == "integer", "sources_meta.version must be INTEGER"
assert ver_col[1] == "NO", "sources_meta.version must be NOT NULL"
assert ver_col[2] == "0", "sources_meta.version must default to 0"
updated = _column(db, "sources_meta", "updated_at")
assert updated is not None, "sources_meta.updated_at is missing"
assert updated[0] == "timestamp with time zone", (
"sources_meta.updated_at must be TIMESTAMPTZ"
)
assert updated[1] == "NO", "sources_meta.updated_at must be NOT NULL"
assert str(updated[2]).startswith("now("), (
"sources_meta.updated_at must have server default now()"
)
assert _seed_row(db) == (1, 0), "the seed row (id 1, version 0) is missing"
stamp = _column(db, "saved_chats", "sources_version")
assert stamp is not None, "saved_chats.sources_version is missing"
assert stamp[0] == "integer", "sources_version must be INTEGER"
assert stamp[1] == "NO", "sources_version must be NOT NULL"
assert stamp[2] == "0", "sources_version must default to 0"
row = db.execute(
text("SELECT title, sources_version FROM saved_chats WHERE id = :i"),
{"i": legacy},
).fetchone()
assert row is not None, "the pre-0010 row must survive the upgrade"
assert row[1] == 0, "a pre-0010 row must upgrade stamped 0 (pre-counter KB)"
finally:
_delete(db, legacy)
def test_inserted_rows_round_trip_the_stamp(db: Session, alembic: Config) -> None:
"""At 0010, an omitted stamp defaults to 0 and an explicit stamp
round-trips verbatim."""
command.upgrade(alembic, "head")
default_id = _insert(db, None)
explicit_id = _insert(db, 7)
try:
rows = db.execute(
text("SELECT sources_version FROM saved_chats WHERE id IN (:a, :b)"),
{"a": default_id, "b": explicit_id},
).all()
stamps = {row[0] for row in rows}
assert stamps == {0, 7}, "default stamp 0 and explicit stamp 7 must round-trip"
default_stamp = db.execute(
text("SELECT sources_version FROM saved_chats WHERE id = :i"),
{"i": default_id},
).scalar_one()
assert default_stamp == 0, "an omitted stamp must default to 0"
finally:
_delete(db, default_id)
_delete(db, explicit_id)
def test_downgrade_to_0009_drops_both(db: Session, alembic: Config) -> None:
"""Downgrade to 0009: the stamp column and the counter table are
gone (A13 — reversible) while the rest of ``saved_chats`` survives."""
command.downgrade(alembic, "0009")
assert _version(db) == "0009"
assert _column(db, "saved_chats", "sources_version") is None, (
"sources_version must be dropped"
)
assert not _table_exists(db, "sources_meta"), "sources_meta must be dropped"
id_col = _column(db, "saved_chats", "id")
assert id_col is not None and id_col[0] == "uuid", (
"saved_chats.id must survive the downgrade"
)
token_col = _column(db, "saved_chats", "share_token")
assert token_col is not None and token_col[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
def test_upgrade_round_trip_restores_both(db: Session, alembic: Config) -> None:
"""Downgrade to 0009, then upgrade back to 0010: the counter table
(with a fresh seed row) and the stamp column are back."""
command.downgrade(alembic, "0009")
command.upgrade(alembic, "0010")
assert _version(db) == "0010", "round-trip upgrade must land at 0010"
assert _table_exists(db, "sources_meta"), "sources_meta must be back"
assert _seed_row(db) == (1, 0), "the seed row must be re-seeded on upgrade"
stamp = _column(db, "saved_chats", "sources_version")
assert stamp is not None, "sources_version must be back after the round-trip"
assert stamp[0] == "integer" and stamp[1] == "NO", (
"sources_version must be INTEGER NOT NULL after the round-trip"
)
assert stamp[2] == "0", "sources_version must default to 0 after the round-trip"
+42
View File
@@ -39,6 +39,15 @@ real ``LLMClient`` the probe is stubbed (:func:`_stub_probe`) so no
test ever hits the network; the ``_real_llm`` tests get a passing
probe from ``FakeEmbedder.embed_one``/``chat``.
Phase 53 (task 02): the sources-version bump — a sync whose import
changed the KB (added + updated + pruned > 0) advances the single-row
``sources_meta`` counter exactly once (the new generation lands in the
``/api/sync/status`` detail as ``sources_version``); an unchanged
re-sync never bumps (the detail still reports the current generation),
and every failure path (git error, model down) never bumps. The
counter is pinned to the migration-0010 seed (0) around every test by
:func:`_reset_sources_version`.
The git / import / overview layers are monkeypatched in ``app.api.sync``
(same fake style as ``test_import_docs_git.py``) — no real git, no LLM:
the runner's state machine and HTTP surface are under test.
@@ -66,11 +75,13 @@ from sqlalchemy.orm import Session
from app.api import sync as sync_api
from app.config import Settings
from app.db import SessionLocal, db_available
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag import git_sources as git_sources_resolver
from app.rag.importer import ImportSummary
from app.rag.llm import EmbeddingError, LLMClient, ModelUnavailableError
from app.rag.sources_meta import current_sources_version
from scripts.git_sync import GitSyncError
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@@ -100,6 +111,26 @@ def clean_git_sources(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""Phase 53: the sources version counter is global mutable state —
pin it to the migration-0010 seed (0) around every sync test so the
bump assertions start from a known generation (own session: the
runner bumps through its own short-lived ``SessionLocal``).
Skips like the ``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
@pytest.fixture()
def sync_client() -> Iterator[TestClient]:
"""Context-managed TestClient — one app event loop across requests
@@ -310,7 +341,10 @@ def test_admin_sync_success_reports_full_detail(
"files": 5, "added": 1, "updated": 2, "unchanged": 2, "pruned": 3,
"errors": 0, "chunks": 11, "summaries": 1, "summary_errors": 0,
"overview": True,
"sources_version": 1, # phase 53: changed KB → exactly one bump (0 → 1)
}
# The bump committed: the counter advanced exactly once, not twice.
assert current_sources_version(db) == 1
# Git: the configured repo was cloned into BOR_SOURCES_DIR/<repo-name>/.
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")]
# Import: exactly the checkouts, with prune=True (the button is the
@@ -354,6 +388,10 @@ def test_unchanged_kb_skips_overview_refresh(
assert body["detail"]["overview"] is False
assert fake_overview.llms == [] # no wasted model call
assert len(fake_import.llms) == 1 # the import itself ran
# Phase 53: an unchanged re-sync never bumps — the detail reports
# the current (unadvanced) generation.
assert body["detail"]["sources_version"] == 0
assert current_sources_version(db) == 0
# --- admin: concurrency ----------------------------------------------------
@@ -435,6 +473,8 @@ def test_git_failure_marks_failed_and_skips_import(
assert body["finished_at"] is not None
assert fake_import.sources == [] # no partial import
assert fake_overview.llms == []
# Phase 53: a FAILED sync never bumps — the version is untouched.
assert current_sources_version(db) == 0
# A failed run leaves the system restartable: a new POST is accepted.
assert sync_client.post("/api/sync").status_code == 202
@@ -773,6 +813,8 @@ def test_model_down_fails_fast_before_any_clone(
assert body["detail"] == {}
assert clone_calls == [] # fail fast: before any clone
assert fake_import.sources == [] # and before any import
# Phase 53: a FAILED sync (model down) never bumps.
assert current_sources_version(db) == 0
def test_probe_names_dead_embed_model_and_masks_credentials(
+98 -17
View File
@@ -17,7 +17,13 @@ regression is caught without a browser:
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
the empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in ``history.html``, and the ``.stale-pill``
rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
"""The table skeleton: ``.history-table`` with the six columns —
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
Actions (the Actions header text is visually-hidden — the row
buttons carry their own aria-labels) — and the empty-state row
(ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
'<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
# The Stale column (phase 53) sits BETWEEN Updated and Share —
# i.e. between Updated and Actions — so the phase-51 contract
# (Share between Updated and Actions) still holds.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Stale</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
), "the Stale column must sit between Updated and Share"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
# Share, phase 53 added Stale), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"5\">" in html
assert "<td colspan=\"6\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
history.html is Title | Messages | Updated | Stale (phase 53) |
Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
stale_i = row.find('staleTd.className = "history-stale-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
assert -1 < updated_i < stale_i < share_i < actions_i, (
"the stale cell (phase 53) must sit between Updated and Share —"
" i.e. the share cell must still sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
assert seq == [
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
# ---------- the Stale column (phase 53, task 04) ----------
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
"""makeRow (phase 53 task 04): the Stale cell renders from the
row's ``stale`` flag — the SERVER computes staleness (task 03),
the client never does version math. Stale rows: the rose
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
at the Regenerate action on the chat page, task 05). Fresh rows:
a plain em-dash (no pill). The <td> carries its own aria-label in
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
visual). READ-ONLY badge: the cell binds no events and creates no
controls (the Regenerate button lives on the chat-page banner);
textContent only (XSS contract)."""
js = _js()
row = _fn(js, "makeRow")
assert 'staleTd.className = "history-stale-cell"' in row
assert "tr.appendChild(staleTd)" in row
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
# The stale branch: the rose pill with the exact hover copy.
assert 'pill.className = "stale-pill"' in branch
assert 'pill.textContent = "Stale"' in branch
assert (
'pill.title = "Sources have changed since this chat was saved'
" — open the chat to Regenerate\";"
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
# The fresh branch: the plain em-dash, never the pill.
else_i = branch.find("} else {")
assert else_i != -1, "the fresh branch must exist"
fresh = branch[else_i:]
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
# The <td> aria-label ships in BOTH states (conveyed without the
# visual — WCAG 2.1 AA).
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
"the cell's aria-label must exist in the stale AND the fresh branch"
)
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
# cell's construction.
assert "addEventListener" not in branch
assert "createElement(\"button\")" not in branch
assert "innerHTML" not in branch
def test_stale_column_css_rose_family() -> None:
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
err-line border), theme-token based so it stays AA with the
palette; a compact rounded pill (border-radius 999px, nowrap). The
``.history-stale-cell`` keeps the marker on one line and rides
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
css = _css()
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
assert pill, "styles.css must style .stale-pill"
body = pill.group(1)
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
assert "color: var(--err-ink)" in body
assert "border: 1px solid var(--err-line)" in body
assert "border-radius: 999px" in body, "the pill shape"
assert "white-space: nowrap" in body
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
assert cell, "the stale cell must be styled"
cbody = cell.group(1)
assert "white-space: nowrap" in cbody
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
def test_share_control_three_states_and_two_step_unshare() -> None:
+224
View File
@@ -447,3 +447,227 @@ def test_share_button_revealed_only_for_admin() -> None:
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
)
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
def test_stale_banner_html_after_kb_banner() -> None:
"""index.html: the #stale-banner section sits DIRECTLY AFTER
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
the top slot when both are visible), role="status", shipped hidden,
with the exact text and the #stale-regenerate button (type=button,
visible label "Regenerate", the redo glyph — the SAME SVG paths as
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
carries it (chat-page only)."""
html = _index()
kb_idx = html.find('id="kb-banner"')
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert banner, "index.html must contain the #stale-banner section"
tag = banner.group(0)
assert 'role="status"' in tag
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
# Nothing between the kb-banner close and the stale banner except
# whitespace + the phase-53 comment: the top-of-column pair is kept.
between = html[html.find("</div>", kb_idx) : banner.start()]
assert "id=" not in between, "no other element lands between the two banners"
block = html[banner.start() : html.find("</section>", banner.start())]
assert "The sources have been updated since this chat was saved." in block
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
assert btn, "the banner carries the #stale-regenerate button"
assert 'type="button"' in btn.group(0)
btn_block = block[btn.start() : block.find("</button>", btn.start())]
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
assert 'd="M21 3v5h-5"' in btn_block
# The banner's own leading mark is the redo glyph too (distinct from
# the kb-banner warning triangle) — aria-hidden decoration.
lead = block[: btn.start()]
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the stale banner is chat-page only"
)
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
#stale-banner is revealed (hidden removed) — the flag is
server-computed (task 03), the client never does staleness math.
The reveal rides the boot SUCCESS path (after the link + the
localStorage mirror), so a non-stale payload leaves the banner
hidden."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
link_i = body.find("currentChatId = chatId")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
"""The no-brain-record guard: a stale conversation with NO brain
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
button is removed BEFORE the reveal (retryLastTurn is never called
in that state)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
remove_i = body.find("staleRegenBtn.remove()")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < guard_i < remove_i < reveal_i, (
"the no-brain check removes the button before the banner is revealed"
)
def test_retry_last_turn_returns_the_turn_promise() -> None:
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
the Regenerate path awaits the turn's completion to know when to
persist. The phase-49 Retry click handler ignores the return value
— behavior-neutral for it (the redo-order pins in
test_frontend_feedback.py keep holding unchanged)."""
js = _js()
body = _fn(js, "retryLastTurn")
assert "return runTurn(text, { reask: true })" in body, (
"the redo promise is returned for the Regenerate await"
)
assert "void runTurn" not in body, "the fire-and-forget void is gone"
# The existing Retry click handler still ignores the return value.
append = _fn(js, "appendRetryButton")
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
resolves nothing), AWAITs the returned turn promise, and persists
only when the turn completed WITHOUT the error banner (a
mid-stream error leaves the linked row untouched — stale stays
true). The double-click guard releases in the finally — never
stale (PLAN §7.4)."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
call_i = body.find("retryLastTurn(lastBrainWrap)")
await_i = body.find("await turn")
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
err_i = body.find('banner.classList.contains("is-error")')
put_i = body.find('`/api/chats/${currentChatId}`')
assert -1 < await_i < err_i < put_i, (
"the error-banner check sits between the await and the persist"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile)
follows saveCurrentChat's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it);
network → the reachable? banner."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "if (currentChatId)" in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body and 'method: "POST"' in body
put_idx = body.find('method: "PUT"')
post_idx = body.find('method: "POST"')
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
# The 404→recreate fallback: unlink, then POST again.
notfound_idx = body.find("res.status === 404")
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# The recreate links the new row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
# Success: hide the banner, then announce in the live region.
hide_i = body.find("staleBanner.hidden = true")
ann_i = body.find('sendStatus.textContent = "Regenerated')
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
assert "the answer now reflects the current sources." in body, ("the live-region line")
# Failures raise an actionable banner (non-ok HTTP + network).
assert (
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
)
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
def test_stale_regenerate_binding_and_element_queries() -> None:
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
module scope and binds the click to regenerateStaleChat. The banner
only ever shows on the /?chat=<id> boot path (admin), so the
binding is inert otherwise."""
js = _js()
assert 'document.querySelector("#stale-banner")' in js
assert 'document.querySelector("#stale-regenerate")' in js
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful
manual re-Save re-stamps the row to the current generation (task
03) — the banner is done the moment the save succeeds."""
js = _js()
new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "saveCurrentChat")
saved_line = 'sendStatus.textContent = "Conversation saved."'
after = save_body[save_body.find(saved_line):]
assert "staleBanner.hidden = true" in after, (
"a successful re-save re-stamps the row — the banner is done"
)
def test_stale_banner_css_is_the_kb_banner_family() -> None:
"""styles.css: the banner rides the .kb-banner family (the section
carries BOTH classes — the flex row + accent tokens come from
.kb-banner; .stale-banner adds the wrap so the pill can drop below
the text when the row must wrap), and the .stale-regenerate pill is
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
redo glyph). The ≤640px block makes the pill a full-width row."""
html = _index()
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
"the section carries both classes — the family comes from .kb-banner"
)
css = _css()
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .stale-regenerate"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"border-radius: 999px",
"border: 0",
"background: var(--brand)",
"color: var(--bg)",
"font-weight: 700",
"cursor: pointer",
):
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
"the redo glyph rides the 16px pill size"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
"at phone width the pill takes a full-width row"
)
+101
View File
@@ -0,0 +1,101 @@
"""Unit: sources-version counter helpers (phase 53, task 01).
``app.rag.sources_meta`` runs against the local compose Postgres
(``podman compose up -d db``) — the house DB-test pattern: the helpers
are thin session wrappers whose contract (seeded single row, flush-not
commit, defensive absence) only holds against a real database. Skips
with clear instructions when the stack is not up.
A fixture resets ``sources_meta`` to the migration-0010 seed state
(id 1, version 0) before and after every test, so the suite leaves the
dev DB exactly as the migration left it.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import SourcesMeta
from app.rag.sources_meta import bump_sources_version, current_sources_version
@pytest.fixture()
def seeded_sources_meta(db: Session):
"""Reset the counter to the migration-0010 seed (id 1, version 0)."""
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
yield
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
def test_current_absent_row_returns_zero_without_raising(
db: Session, seeded_sources_meta: None
) -> None:
"""Defensive: a deleted seed row reads as 0 — never an exception."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert current_sources_version(db) == 0
def test_first_bump_zero_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Seeded at 0 (the pre-counter KB), the first bump returns 1 and
``current`` reflects it."""
assert current_sources_version(db) == 0
assert bump_sources_version(db) == 1
db.commit() # the caller commits — the helper only flushes
assert current_sources_version(db) == 1
def test_second_bump_increments(db: Session, seeded_sources_meta: None) -> None:
"""Bumps are monotonic: 0 → 1 → 2, one step per KB-changing sync."""
assert bump_sources_version(db) == 1
db.commit()
assert bump_sources_version(db) == 2
db.commit()
assert current_sources_version(db) == 2
def test_bump_absent_row_upserts_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Upsert semantics: a deleted seed row is recreated by the first
bump (version 0 → 1), never left dangling."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert bump_sources_version(db) == 1
db.commit()
row = db.get(SourcesMeta, 1)
assert row is not None, "the single row must be recreated"
assert row.id == 1
assert row.version == 1
assert row.updated_at is not None, "updated_at must be server-stamped"
def test_bump_flushes_without_committing(db: Session, seeded_sources_meta: None) -> None:
"""The helper flushes, it does not commit: a rolled-back session
must roll the bump back with it (each sync path owns its
transaction)."""
assert bump_sources_version(db) == 1
db.rollback()
assert current_sources_version(db) == 0, "the uncommitted bump must roll back"
def test_bumps_in_separate_sessions_progress(db: Session, seeded_sources_meta: None) -> None:
"""One writer at a time is the deployment reality, but two bumps in
two sessions must not race to the same value: the second session
sees the committed increment and lands on the next generation."""
assert bump_sources_version(db) == 1
db.commit()
other = SessionLocal()
try:
assert bump_sources_version(other) == 2
other.commit()
finally:
other.close()
db.expire_all() # drop the stale identity-map state
assert current_sources_version(db) == 2