phase: 187_sync_peak_memory_line
**Phase 187 — final verification pass: all criteria met, repo 100% green.**
Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.
Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)
Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.
Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).
Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.
Next pending phase: `188_chunks_hnsw_index`.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Phase 189 — Long agent turns save (and share) the whole conversation
|
||||
|
||||
**Source:** owner incident 2026-10-16 — the shared chat `0101d2ca-b24c-4d13-9f65-c0837e993c50` (row `8b345ab5-aab3-4412-9642-0f869b3cec4d`) shows ONLY the user's question although the LLM answered it completely; the shared row `be92e464-7918-4907-9186-09d8d98ff2c1` carries the same signature.
|
||||
**Story:** n/a (incident-derived, owner directive 2026-10-16)
|
||||
**Depends on:** none functionally (builds on the completed phase-55/83/158/164 saved-chat surface).
|
||||
|
||||
## Evidence (diagnosed 2026-10-16, production DB + app logs — read this, not the chat)
|
||||
|
||||
1. The row behind the shared token: `n_msgs = 1`, `who0 = user`, `created_at == updated_at` — the row was written EXACTLY ONCE (the send-time auto-save `POST /api/chats`) and never updated. 84 other single-write user-only rows exist (2 shared, incl. this one); the 2-message rows (279) were written >1 time — the pattern is specific to the *last* save point of a conversation.
|
||||
2. The app log for this row shows the whole sequence: the turn's per-turn line (`thinking=xhigh thinking_chars=23937 tool_calls=33 rounds=34 cap_hit=0 total_ms=400173`, `POST /api/chat 200 stream=1` — the client consumed the FULL 400 s stream, so the done frame landed) followed by **`PUT /api/chats/8b345ab5… 422 Unprocessable Entity`** — the done-time auto-save REJECTED — then `GET /api/chats/… 200`, `POST …/share 200` (×2), `GET 200`. The share only minted a token on the stale row.
|
||||
3. WHY the 422: the turn's done frame carried **22 sources** (counted from the per-turn log line's `sources=[…]`), but `ChatMessage.sources` is capped at **20** (`app/schemas.py` L1460 — phase 83 A1 "top-N docs + agent reads"). The cited tier is the READ DOCS since phase 119 (`merge_cited_docs`, `app/api/chat.py` L587/L1289 — retrieval top-N ∪ the agent's read docs, NOT bounded by `top_n_docs`), and production runs `BOR_AGENT_MAX_ROUNDS=50` (the app container's env; the config default is 10) — so a long research turn cites 22+ docs routinely and the done-time auto-save 422s. The same turn had **33 tool calls** (`tools` cap 50 — at the production round cap with ZERO headroom) and `segments` entries = 33 tool entries + one thinking entry per contiguous thinking run (`buildSegments`, `app.js` L1775) under `xhigh` thinking over 34 rounds — the `segments` cap of 50 (phase 158, docstring sized "the round cap is 10 → ≤ 20 entries") is exceeded by the same class of turn. `suggestions` (cap 50) and `related` (cap 20) are likewise sized against settings (`suggested_docs`, `related_max_docs`) whose validators are unbounded above.
|
||||
4. WHY the 422 lost the answer: the phase-55 A2 quiet contract swallows a failed auto-save — a one-line `#send-status` note ("Couldn't save automatically — will try on the next message."), no error banner — and NOTHING retries: the retry is the NEXT save point (next user send / next brain-done / the pagehide partial), which never came (this was the last turn). The row stayed at its pre-turn state.
|
||||
5. WHY the share froze the loss: `shareCurrentChat`'s LINKED branch (`app.js` L3345) only calls `POST /api/chats/<id>/share` (token mint, `app/api/chats.py` `share_chat`) — it never re-saves the current conversation, so the shared snapshot is whatever the row last held.
|
||||
6. SECOND loss path (same symptom class): `persistConversation`'s `persisting` double-fire guard (`app.js` L3216) DROPS a save point that arrives while an upsert is in flight ("the next save point retries" — which, per 4., may never come). A done-time save dropped by a still-in-flight send-time save (a 400 s turn on a slow line) leaves the same user-only row.
|
||||
|
||||
## Objective
|
||||
A long agent turn (production `BOR_AGENT_MAX_ROUNDS=50`) saves cleanly at every save point: the saved-chat schema caps move from the round-cap-10 era to the real configuration (A1); a Share never publishes a row the current conversation has outgrown — it saves first (A2); a dropped or failed save self-heals (overlap re-run, one transient retry) and a failed save STAYS visible until the next success (A3/A4).
|
||||
|
||||
## Context
|
||||
- `app/schemas.py` — `ChatMessage` (L1381; `extra="forbid"`, L1458): `text`/`thinking` `max_length=600_000` (phase 164), `sources` `max_length=20` (L1459), `related` `max_length=20`, `suggestions` `max_length=50` (items `_Chip` ≤ 200), `tools` `max_length=50`, `segments` `max_length=50` (L1479), `error` ≤ 500, `image` ≤ 500, `answer_image` `SourceRef`, `answer_text` `max_length=50`, `budget_exhausted`; `SourceRef` (L1260-ish: `source` ≤ 120 / `path` ≤ 1000 / `title` ≤ 500 / `image_url`), `ToolCall` (L1209: `name` ≤ 100, `argument` ≤ 2000, `chars_shown`/`chars_total`/`offset`, the phase-139 `range` + phase-146 `path` additive keys), `SavedChatCreate`/`SavedChatUpdate` (`messages` `min_length=1, max_items=500` — phase 164; the `_require_within_total_budget` validator), `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8_000_000` (L1611). The phase-83 docstrings on the list fields still say "top-N docs + agent reads — the UI shows a handful" — that assumption is RETIRED (see evidence 3).
|
||||
- `app/api/chat.py` — `merge_cited_docs` (L587), the done-event build (L1402–1447: `cited_refs` = the merged read docs, `related_refs` capped by `related_max_docs`), `app/rag/retriever.py` (the tiering), `app/config.py` (`agent_max_rounds` L279, validator L—: `>= 0`, NO upper bound; `suggested_docs` / `related_max_docs` validators likewise unbounded above; production env `BOR_AGENT_MAX_ROUNDS=50`).
|
||||
- `app/api/chats.py` — the public write surface (phase 55 A1), `share_chat` (token-only, L379-ish), the 422 is the framework's standard validation response (phase 83 A2: boundary-only, no route changes).
|
||||
- `frontend/assets/app.js` — `persistConversation` (L3218: the `persisting` guard L3216, the 404→recreate fallback, the A2 quiet contract on failure), `rememberBrainTurn` (L3484 — save point 2, rides L3496), the send save point (L1371), `shareCurrentChat` (L3345: linked → `POST /{id}/share` only; unlinked → `POST /api/chats {messages, share: true}`), `regenerateStaleChat` (L3419 — its own upsert copy), the pagehide partial (L4821, rides `rememberBrainTurn`), `buildSegments` (L1775), `sendStatus` (the `#send-status` live region).
|
||||
- `tests/unit/test_schemas.py` — the boundary-constant block (L47–75: `SOURCES_CAP = 20`, `SUGGESTIONS_CAP = 50`, `TOOLS_CAP = 50`, `TEXT_CAP = 600_000`, …) and the at-cap/one-over pins per field; `tests/integration/test_chats_api.py` — `test_post_rejects_sources_over_20_and_stores_nothing` (L1862), the oversized-422 pins, the full brain-record pin; `tests/e2e/test_chat_save_payload_limits.py` (oversized-TEXT 422 + small-save 201 — text caps UNCHANGED, this suite is unaffected); `tests/unit/test_save_chat_ui.py` (the JS marker pins for the save/share contract — the A2 quiet-contract pin names the status-note text and the `persisting` guard); `tests/e2e/mock_llm.py` (the deterministic mock turn: single-read tool flow, `slow_llm.py` for timing).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
|
||||
- **A1 — the caps move to the real configuration (task 01; `app/schemas.py` ONLY, boundary-only — the phase-83 A2 style: NO route change, the 422 stays the framework's standard response).** The five list caps change, every other bound (per-message `text`/`thinking` 600 000, `error` 500, `image` 500, the `SourceRef` field caps, the `ToolCall` field caps, `answer_text` 50, the `max_items=500` messages cap, `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8 000 000`) is UNCHANGED:
|
||||
- `sources` 20 → **200** — the cited tier is top-N ∪ the agent's READ docs (`merge_cited_docs`), so its size is bounded by `agent_max_rounds` (production 50, validator-unbounded) × calls-per-round, not by `top_n_docs`; 200 = 4× the production round cap (each round typically carries one `read`);
|
||||
- `related` 20 → **200** — `related_max_docs` is owner-tunable with no upper bound; one `SourceRef`-family cap with `sources`;
|
||||
- `tools` 50 → **200** — one-or-more calls per round are legal (a round is one model request, not one call); 4× the production round cap;
|
||||
- `segments` 50 → **450** — ≤ `tools` (200) tool entries + ≤ ~50 thinking runs (one per round, the cap-50 worst case) + margin;
|
||||
- `suggestions` 50 → **100** — `suggested_docs` is owner-tunable with no upper bound; 20× the default (5); the item cap (200 chars) is unchanged.
|
||||
The SEC-05 intent survives: the worst-case list bytes are orders of magnitude inside the 8 M TOTAL budget (`200 SourceRefs ≈ 360 KB`, `200 ToolCalls ≈ 440 KB`, `450 segments ≈ 1 MB`), and the fixed boundary still bounds item COUNT for an operator who raises `BOR_AGENT_MAX_ROUNDS` far beyond 50. The `ChatMessage`/`SourceRef` docstrings are updated: the "top-N + a handful" sizing rationale is replaced by the `merge_cited_docs` / `agent_max_rounds` reality (evidence 3), the phase-83 history KEPT (the 32 000 mirror is phase 164's 600 000), and a note that the 8 M total budget is the byte bound. The unit constants (`SOURCES_CAP`, `TOOLS_CAP`, `SUGGESTIONS_CAP`) and the integration pins move with the values.
|
||||
- **A2 — Share saves first (task 02; `shareCurrentChat`, `app.js` L3345).** The LINKED branch upserts the CURRENT conversation BEFORE minting the token — the SAME upsert semantics as `persistConversation` (PUT; a 404 unlinks and retries as a create), then `POST /api/chats/<id>/share`. An upsert FAILURE (non-ok or network) ABORTS the share with the existing neutral error banner ("Couldn't share the conversation — try again.") — NO token, NO toast, `shareBtn` released in the `finally` (the never-stale contract, PLAN §7.4). The UNLINKED branch is unchanged (its `POST {messages, share: true}` already saves the current conversation in one commit). Docstring: sharing a conversation that the row has not yet caught up would publish a truncated snapshot (the incident) — the upsert-first ordering makes the shared page the current conversation.
|
||||
- **A3 — a dropped save re-runs (task 03; `persistConversation`, `app.js` L3216–3260).** `persisting` stays the one-upsert-at-a-time guard, but a save point that arrives while an upsert is in flight sets a module-level `savePending = true` instead of vanishing; the in-flight upsert's `finally` (after `persisting = false`) re-runs `persistConversation()` once when `savePending` is set (the flag is cleared first; the re-run is a fresh, serialized call — never concurrent with the upsert it trails). The re-run carries the LATEST `conversation` (the array is read at call time), so the dropped done-time save lands with the brain record. No new save points are added — the existing ones (send, `rememberBrainTurn`/pagehide, regenerate) are unchanged.
|
||||
- **A4 — a failed save stays visible and retries once (task 03).** On failure (non-ok or network), the existing one-line note is kept AND a module flag `saveFailedSticky = true` set — the note persists until the NEXT SUCCESSFUL save (a success clears the flag and resets `#send-status` to empty when the flag was set; other flows may overwrite the note in the meantime — best-effort visibility, the flag + the next save point are the guarantee). A TRANSIENT failure (network error or 5xx status) additionally schedules ONE retry after 5 000 ms (a `setTimeout` + a one-shot `retryScheduled` guard so overlapping failures never stack retries; the retry is a plain `persistConversation()` — the LATEST conversation, the same guards). A 4xx NEVER retries (deterministic — the cap fix in task 01 is the remedy; the retry would 422 again). The A2 contract's shape is unchanged: still a one-line `#send-status` note, still NO error banner, the turn never blocks — only the note's lifetime and the one transient retry are added. `regenerateStaleChat`'s own upsert copy is LEFT AS IS (its failure path already errors visibly through the turn's banner state, and A1 removes the 422 class it could hit).
|
||||
|
||||
## Tasks
|
||||
1. `01_schema_cap_realign.md` — the five list-cap moves + docstring rationale (A1), the unit boundary pins at the NEW values, the incident-payload integration pin (22 sources / 51 segments / 201 tools now 201/200), the oversized-422 pins at the new boundaries.
|
||||
2. `02_share_saves_first.md` — `shareCurrentChat`'s linked branch upserts before minting the token, aborts the share on an upsert failure; the JS marker pins.
|
||||
3. `03_persist_rerun_sticky.md` — the `savePending` overlap re-run, the sticky failure note, the one transient retry; the JS marker pins.
|
||||
4. `04_e2e_and_gates.md` — the dedicated Playwright suite (the incident regression at the wire + the overlap / share-saves-first behavior through the real UI), the full gates, the docs pass, the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- **Unit:** `tests/unit/test_schemas.py` — the NEW boundary pairs per field (200/201 `sources`, 200/201 `related`, 200/201 `tools`, 100/101 `suggestions`, 450/451 `segments`), the realistic-payload round-trip pin EXTENDED (a 22-source / 34-tool / 40-segment brain record validates and round-trips byte-identically), every pre-phase pin green. `tests/unit/test_save_chat_ui.py` — the A2/A3/A4 marker pins (the upsert-before-share ordering in `shareCurrentChat`'s linked branch, the abort-on-failure path, `savePending` + the `finally` re-run, the sticky flag + the 5 s one-shot retry, the 4xx-no-retry distinction; the existing A2 quiet-contract pins stay green — the note text and the "no error banner" shape are unchanged).
|
||||
- **Integration:** `tests/integration/test_chats_api.py` — anonymous `POST`/`PUT` with the INCIDENT payload shape (22 `sources`, 33 `tools`, 51 `segments` incl. thinking runs) → **201/200** (pre-fix: 422); the over-cap pins move to the new boundaries (201 `sources` → 422, no row; 201 `tools` → 422; 451 `segments` → 422; 101 `suggestions` → 422); the full brain-record pin + the stale/share pins stay green.
|
||||
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_chat_save_long_turns.py`, run **in isolation** — (a) the incident regression at the wire through `page.request` (22-source brain record → 201, pre-fix 422); (b) a real UI mock turn's done-time auto-save lands (row: 2 messages); (c) the overlap re-run (the send-time save's PUT delayed 3 s via a Playwright route, the mock turn settles in flight → the dropped done-time save re-runs → final row 2 messages); (d) share-saves-first (with the auto-saves still delayed, the Share click → the shared page renders the BRAIN message, not just the question).
|
||||
- **Regression:** `tests/e2e/test_chat_save_payload_limits.py`, `tests/e2e/test_chat_history.py`, `tests/e2e/test_share_chat.py` (the phase-51/55 share contract) green unedited.
|
||||
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); the changed file is `app/schemas.py` (declarative — exercised by every unit/integration pin); the frontend change is marker-pinned per the house JS convention.
|
||||
- **Lint/types:** `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Documentation & Consistency
|
||||
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
|
||||
- `README.md`: the saved-chat section gains the ops note — a long turn (many cited docs / tool rounds) saves cleanly (the caps now follow the agent's real round cap), Share saves the current conversation before minting the token, a failed auto-save stays visible until the next success and retries once on a transient failure (task 04). `AGENTS.md`: nothing to change unless the final pass finds the phase-55 save contract described elsewhere (the save points are not enumerated in AGENTS.md — the UI-Structure rule's "in-flight turn" clause is untouched; verify, do not add).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `ChatMessage` list caps: `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; every other bound unchanged; the docstring rationale updated (A1).
|
||||
- [ ] The incident payload (22 sources / 33 tools / 51 segments) saves 201/200 anonymously; over-cap variants 422 with no row stored (unit + integration pins).
|
||||
- [ ] A linked Share upserts the current conversation before minting the token and aborts (banner, no token) on an upsert failure; the unlinked branch is byte-identical (A2, marker pins).
|
||||
- [ ] A save point dropped by an in-flight upsert re-runs once after it settles (A3, marker pin); a failed save's note persists until the next success, one 5 s retry on network/5xx only (A4, marker pins).
|
||||
- [ ] `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` green **in isolation** (the four scenarios); `test_chat_save_payload_limits.py`, `test_chat_history.py`, `test_share_chat.py` green unedited.
|
||||
- [ ] `uv run pytest` 100% green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`, the test files, the phase files (NO `app/api/chats.py` diff, no migration, no new dependency).
|
||||
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(chats): save long agent turns — realign schema caps, share saves first, dropped saves re-run`); phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Owner action (data repair — NOT code, recorded here so the phase's commit message can cite it)
|
||||
The two affected shared rows (`8b345ab5…`, `be92e464…`) still hold the user-only snapshot. Their brain answers were never persisted (the SSE bytes are not stored server-side). If the owner's browser still carries the conversations (`bor.chat.v1` localStorage restore), opening the chat page (which re-links to the row) and letting the FIXED client's next save point (or a Share — now save-first) fire restores the full content through the existing upsert; otherwise the answers are unrecoverable and the rows may be deleted from History.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 01 — The saved-chat list caps move to the real agent configuration (A1)
|
||||
|
||||
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Raise the five `ChatMessage` list caps from the round-cap-10 era to the production `BOR_AGENT_MAX_ROUNDS=50` reality (the incident turn's 22-source done frame 422'd at the old 20 cap), update the sizing rationale in the docstrings, and move the boundary pins — so a long research turn's done-time auto-save lands instead of 422ing into the phase-55 quiet-failure, while the SEC-05 boundedness (fixed item-count boundary + the 8 M total budget) survives.
|
||||
|
||||
## Work
|
||||
1. `app/schemas.py` — `ChatMessage` (L1381; the fields at L1458–1481):
|
||||
- `sources: list[SourceRef] | None = Field(default=None, max_length=200)` (was 20)
|
||||
- `related: list[SourceRef] | None = Field(default=None, max_length=200)` (was 20)
|
||||
- `suggestions: list[_Chip] | None = Field(default=None, max_length=100)` (was 50; the `_Chip` 200-char item cap is unchanged)
|
||||
- `tools: list[ToolCall] | None = Field(default=None, max_length=200)` (was 50)
|
||||
- `segments: list[ChatSegment] | None = Field(default=None, max_length=450)` (was 50; the comment's "max_length=50 (the phase-83 voice): far past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches the tools cap" is REPLACED with the phase-189 sizing: ≤ 200 tool entries (4× the production round cap of 50 — a round is one model request, one-or-more calls per round are legal) + ≤ ~50 thinking runs (one per round) + margin)
|
||||
- **UNCHANGED, pinned as-is:** `text`/`thinking` 600 000, `error` 500, `image` 500, `answer_text` 50, the `SourceRef` field caps (120/1000/500 + `image_url`), the `ToolCall` field caps (name 100 / argument 2000 / the additive keys), `SavedChatCreate`/`SavedChatUpdate` `max_items=500`, `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8_000_000` and `_require_within_total_budget` (the verbatim-block accounting untouched).
|
||||
- The `ChatMessage` class docstring: the phase-83 paragraph's "(sources ≤ 20 — top-N docs + agent reads; related ≤ 20 — the related-doc tier, same ref shape; suggestions ≤ 50 chips of ≤ 200 chars; tools ≤ 50 — one entry per tool call, the round cap is 10)" is UPDATED — the cited tier has been the READ DOCS since phase 119 (`merge_cited_docs`, `app/api/chat.py` L587): retrieval top-N ∪ the agent's read docs, bounded by `agent_max_rounds` (production 50 via `BOR_AGENT_MAX_ROUNDS`, the validator L— unbounded above), so the caps are `sources`/`related`/`tools` 200 (4× the production round cap) and `suggestions` 100 (20× the default 5; `suggested_docs` is tunable, unbounded above), `segments` 450; the phase-83 history (the 32 000 mirror raised to 600 000 with phase 164) is KEPT; one sentence added: the fixed boundary bounds item COUNT (an operator raising `BOR_AGENT_MAX_ROUNDS` far past 50 still hits a boundary) while `SAVED_CHAT_TOTAL_CHARS_BUDGET` is the byte bound (worst-case list bytes ≈ 360 KB / 440 KB / 1 MB — orders of magnitude inside 8 M).
|
||||
2. `tests/unit/test_schemas.py` — the boundary-constant block (L47–75): `SOURCES_CAP = 200`, `TOOLS_CAP = 200`, `SUGGESTIONS_CAP = 100` (a `RELATED_CAP = 200` and `SEGMENTS_CAP = 450` constant added in the same style); the existing at-cap/one-over pins (L145–216 and the `segments` pins at L922–1009) now bind the NEW values with no edits (they read the constants); the realistic-payload round-trip pin (around L563) is EXTENDED with the incident shape: a brain record carrying 22 `sources`, 33 `tools`, and 40 `segments` (33 tool entries + 7 thinking entries) validates and `model_dump()`s byte-identically (the `_drop_image_when_absent` omission rules untouched — a record with all the keys present keeps them).
|
||||
3. `tests/integration/test_chats_api.py` — `test_post_rejects_sources_over_20_and_stores_nothing` (L1862) is RENAMED/retargeted to the new boundary (`test_post_rejects_sources_over_200_and_stores_nothing`: 201 `SourceRef` items → 422, no row); NEW pins: (a) `test_post_accepts_the_incident_payload_shape` — an anonymous `POST /api/chats` with a 2-message conversation (user + a brain record carrying 22 `sources`, 33 `tools`, 51 `segments` = 33 tool entries + 18 thinking entries — the incident turn's shape) → **201**, the row's `messages` round-trips all three lists intact; (b) the same shape through `PUT /api/chats/<id>` → **200**, the row's message count bumps to the new array; (c) `test_put_rejects_over_cap_lists_and_leaves_row_unchanged` — 201 `tools` / 451 `segments` / 101 `suggestions` each → **422** and the row's stored messages are byte-identical (the 422 is the framework's standard body — the phase-83 A2 shape, no custom error copy). Every pre-existing pin (the full brain-record pin, the stale/share pins, the 500-item messages cap, the total-budget pins) stays green unedited.
|
||||
4. Verify: `uv run pytest tests/unit/test_schemas.py tests/integration/test_chats_api.py -v`; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the new boundary pairs (200/201 `sources`, 200/201 `related`, 100/101 `suggestions`, 200/201 `tools`, 450/451 `segments`) pinned at BOTH boundaries; the extended realistic round-trip pin.
|
||||
- Integration: the incident payload shape 201/200s anonymously (pre-fix: 422); the over-cap 422 pins at the new boundaries with no row stored / row unchanged; the pre-existing pins green unedited.
|
||||
- Coverage: **>90%** on `app/` (the changed file is `app/schemas.py` — declarative, exercised by every pin).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `ChatMessage` list caps are 200/200/100/200/450 (sources/related/suggestions/tools/segments); every other bound unchanged; the docstring rationale is the `merge_cited_docs`/`agent_max_rounds` one with the 8 M-budget sentence.
|
||||
- [ ] `uv run pytest tests/unit/test_schemas.py tests/integration/test_chats_api.py -v` green — the incident shape 201/200s, the over-cap variants 422 with no row stored, all pre-existing pins green unedited.
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work (the SSE `done` event is untouched — `ChatDoneEvent.sources` has no list cap; the `ChatMessage` caps bind the saved-chat boundary only)
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 02 — Share saves first: a linked Share never publishes a row the conversation has outgrown (A2)
|
||||
|
||||
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Make `shareCurrentChat`'s linked branch upsert the CURRENT conversation (the same upsert semantics as `persistConversation`) BEFORE minting the share token — so a Share can never freeze a stale row (the incident: the row held only the question, the Share minted a token on it, and the shared page showed no answer) — and abort the share with the existing neutral error banner when the upsert fails. The unlinked branch (save-AND-share in one `POST`) is byte-identical.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `shareCurrentChat` (L3345), the linked branch (the `if (currentChatId)` arm, ~L3353–3363) becomes, in order:
|
||||
1. the upsert: `const body = JSON.stringify({ messages: conversation }); const headers = { "Content-Type": "application/json" };` → `res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });` with the SAME 404→recreate fallback as `persistConversation` (L3227–3234): on a 404, `currentChatId = null; res = await fetch("/api/chats", { method: "POST", headers, body });` and, on the 201, re-link (`currentChatId = String((await res.json()).id)`);
|
||||
2. an upsert FAILURE (non-ok `res` after the fallback, or a network `throw`) → `showErrorBanner("Couldn't share the conversation — try again.")` and `return` (NO token minted, NO toast — the existing failure UI, the existing `finally` still releases `shareBtn.disabled`);
|
||||
3. only then the existing `POST /api/chats/<id>/share` mint (unchanged) and the existing success paths (clipboard copy → the inline-field fallback, the `#send-status` line, the task-04 toast on BOTH success branches) — byte-identical.
|
||||
The function docstring gains the phase-189 paragraph: the linked branch upserts first because a Share that minted a token on a row the conversation had outgrown published a truncated snapshot (the incident — the done-time auto-save had 422'd into the quiet-failure and the row held only the question); an upsert failure aborts the share (the owner must never be handed a link to content that is not the current conversation). The unlinked branch's docstring ("one action saves AND shares") is unchanged.
|
||||
2. `tests/unit/test_save_chat_ui.py` — marker pins (the house JS-marker convention, the module's existing style — no browser):
|
||||
- `test_share_linked_branch_upserts_before_the_share_mint`: the linked branch of `shareCurrentChat` contains the PUT to `` `/api/chats/${currentChatId}` `` (the `persistConversation`-shaped upsert) positioned BEFORE the `` `/api/chats/${currentChatId}/share` `` fetch in the function body (a string-ordering pin: the PUT line's index < the share-mint line's index), and carries the 404→recreate fallback markers (the `status === 404` arm + the `POST` to `"/api/chats"` with the same `body`/`headers`);
|
||||
- `test_share_aborts_on_upsert_failure`: the failure arm between the upsert and the mint sets the EXACT neutral banner text `"Couldn't share the conversation — try again."` and `return`s (no `showToast` call between the upsert's failure check and the banner — the toast is success-only, the existing pin stays green);
|
||||
- `test_share_unlinked_branch_unchanged`: the unlinked branch still POSTs `"/api/chats"` with `share: true` in the body and re-links on the 201 (the existing pins, green unedited);
|
||||
- the existing phase-55 pins (the always-visible `#share-chat-btn` markup, the neutral error copy, the `showToast` success-only contract, the double-click guard released in the `finally`) stay green unedited.
|
||||
3. Verify: `uv run pytest tests/unit/test_save_chat_ui.py -v`; `uv run pytest` green.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (JS markers): the upsert-before-mint ordering, the 404→recreate fallback in the share, the abort-on-failure path (banner, no token, no toast), the unlinked branch unchanged; every pre-existing pin green.
|
||||
- Coverage: the frontend change is marker-pinned per the house JS convention (no JS runtime in the unit gate); the behavior is E2E-pinned by task 04.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `shareCurrentChat`'s linked branch: PUT upsert (with the 404→recreate fallback) → failure abort (neutral banner, no token, no toast) → share mint → the existing success paths byte-identical; the docstring says why.
|
||||
- [ ] `uv run pytest tests/unit/test_save_chat_ui.py -v` green — the new marker pins + every pre-existing pin.
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work (the unlinked branch, the toast/banner contract, the `share_chat` endpoint — token-only, `app/api/chats.py` UNTOUCHED)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Task 03 — Dropped saves re-run; a failed save stays visible and retries once (A3/A4)
|
||||
|
||||
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Close the two silent-loss paths in `persistConversation`: a save point dropped by the `persisting` double-fire guard (evidence 6 — a done-time save dropped by an in-flight send-time save vanishes until the next save point, which may never come) re-runs once after the in-flight upsert settles; a failed save's status note STAYS until the next success and a transient failure (network / 5xx) retries once after 5 s — while the phase-55 A2 contract's shape is unchanged (one-line `#send-status` note, NO error banner, the turn never blocks, silent success).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `persistConversation` (L3216–3260) and its guard (L3216):
|
||||
- `let persisting = false;` stays (the one-upsert-at-a-time guard); add `let savePending = false;` (A3) and `let saveFailedSticky = false; let retryScheduled = false;` (A4) at the same module scope, each with a one-line comment naming the phase-189 decision.
|
||||
- **A3 — the overlap re-run:** the top guard becomes `if (persisting) { savePending = true; return; }` (a save point that arrives mid-upsert is PENDING, not dropped). At the top of the body, after the guard: `savePending = false;` (a fresh run consumes the flag — the pending re-run is this run when it was set by a re-run). In the `finally` (after `persisting = false;`): `if (savePending) { savePending = false; void persistConversation(); }` — the re-run is a fresh serialized call (it starts only after the in-flight upsert fully settled; never concurrent with it). The re-run reads `conversation` at call time, so the dropped done-time save lands with the brain record. No new save points, no other function touched.
|
||||
- **A4 — sticky note + one transient retry:** in the failure paths (the `!res.ok` arm and the `catch` arm), after setting the existing note text, set `saveFailedSticky = true;` and — transient only — `if (!retryScheduled && (res.status >= 500 || <network-failure-arm>)) { retryScheduled = true; setTimeout(() => { retryScheduled = false; void persistConversation(); }, 5000); }` (the `catch` arm IS the network-failure arm; the 4xx — every other non-ok — NEVER schedules: a 422 is deterministic, the phase-189 task-01 cap fix is the remedy, and a retry would 422 again; the one-shot `retryScheduled` guard keeps overlapping failures from stacking retries). In the success path (the `staleBanner` clearing block), add: `if (saveFailedSticky) { saveFailedSticky = false; sendStatus.textContent = ""; }` (a success clears the stuck note — the note persists until the next success; other flows may overwrite the note in the meantime, best-effort visibility, the flag + the next save point are the guarantee).
|
||||
- The `persistConversation` docstring: the "double-fire guard" paragraph is updated — a dropped save point is now PENDING and re-runs once after the in-flight upsert settles (A3); the A2 quiet-contract paragraph gains the two phase-189 lines (the note persists until the next success; one 5 s retry on network/5xx, never on 4xx — the note text and the no-banner shape are UNCHANGED).
|
||||
- `regenerateStaleChat` (L4439–4460, its own upsert copy) is LEFT AS IS (its failure surfaces through the turn's banner state; A1 removes the 422 class it could hit).
|
||||
2. `tests/unit/test_save_chat_ui.py` — marker pins (the house JS-marker convention):
|
||||
- `test_persisting_overlap_sets_pending_and_reruns`: `persistConversation` contains the `savePending = true` assignment inside the `if (persisting)` arm, the `savePending = false` reset at the top of the body, and the `finally` block re-runs `persistConversation()` guarded by `savePending` (the ordering: `persisting = false` before the `savePending` check in the `finally`);
|
||||
- `test_failed_save_note_is_sticky_until_success`: the failure arms set `saveFailedSticky = true`, and the success path clears the flag + resets `sendStatus` (the pin: the success block contains the `saveFailedSticky` guard);
|
||||
- `test_transient_failure_retries_once_after_5s`: the retry scheduling is present with the 5000 ms delay, the `>= 500` status condition, the `catch`-arm scheduling, and the one-shot `retryScheduled` guard (set before the `setTimeout`, reset inside it);
|
||||
- `test_4xx_never_retries`: the retry condition is `res.status >= 500` (a 4xx non-ok schedules nothing — the pin: no other `setTimeout` scheduling in the failure arms);
|
||||
- the existing phase-55 A2 pins (the one-line note text `"Couldn't save automatically — will try on the next message."`, no error-banner call in `persistConversation`, the silent-success shape, the `persisting` guard) stay green unedited.
|
||||
3. Verify: `uv run pytest tests/unit/test_save_chat_ui.py -v`; `uv run pytest` green.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (JS markers): the pending re-run (set/reset/finally-re-run ordering), the sticky-note lifetime, the one-shot 5 s transient retry, the 4xx exclusion; every pre-existing pin green.
|
||||
- Coverage: the frontend change is marker-pinned per the house JS convention; the behavior is E2E-pinned by task 04 (the overlap scenario).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `persistConversation`: an overlapping save point sets `savePending`; the `finally` re-runs once after `persisting = false`; a fresh run consumes the flag.
|
||||
- [ ] A failed save's note persists until the next success; a network/5xx failure schedules ONE 5 s retry (one-shot guard); a 4xx never retries; the A2 contract's shape (note text, no banner, silent success) is unchanged.
|
||||
- [ ] `uv run pytest tests/unit/test_save_chat_ui.py -v` green — the new marker pins + every pre-existing pin.
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work (`regenerateStaleChat` untouched; no new save points; `app/api/chats.py` untouched)
|
||||
@@ -0,0 +1,57 @@
|
||||
# Task 04 — The E2E suite, the full gates, the docs pass, the commit
|
||||
|
||||
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Pin the owner-visible contract of the whole phase through the real UI: the incident regression at the wire (a 22-source brain record saves instead of 422ing), a mock turn's done-time auto-save landing, the overlap re-run (a dropped save lands after the in-flight upsert settles), and share-saves-first (a Share with the auto-saves still in flight publishes the BRAIN message, not just the question) — then the full gate, the docs pass, and the atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_chat_save_long_turns.py` — the phase's dedicated Playwright suite (the house `conftest` app + `mock_llm.py` pattern, run **in isolation** per AGENTS rule 4; a fresh saved-chat row per test — the house `saved_chats` cleanup helper pattern from `test_chat_history.py`):
|
||||
- `test_incident_payload_shape_saves_at_the_wire`: through `page.request` (the `test_chat_save_payload_limits.py` pattern — the app's own origin), an anonymous `POST /api/chats` with the INCIDENT shape (a user message + a brain record carrying 22 `sources` (`SourceRef` dicts at realistic column-length values), 33 `tools` (`ToolCall` dicts), 51 `segments` = 33 `{kind: "tool", …}` + 18 `{kind: "thinking", text: …}`) → **201**; a follow-up `PUT` replacing the brain record with the same shape → **200** and the row's `messages` round-trips all three lists (a `GET /api/chats/<id>` admin assertion). (Pre-fix: both 422 — the incident.)
|
||||
- `test_done_time_auto_save_lands_for_a_mock_turn`: the real UI — send a mock question (the `mock_llm.py` no-tools flow, the `test_chat_history.py` send pattern), wait for the done bubble (the house `wait_for_answer` helper), then `GET /api/chats/<id>` (the row id from the created-response `#…` — the house pattern reads `currentChatId` via `page.evaluate`): the row has **2** messages and the brain record's `text` equals the mock answer.
|
||||
- `test_dropped_done_time_save_reruns_after_the_inflight_upsert_settles`: `page.route`-delay the FIRST PUT to `/api/chats/**` (the send-time auto-save) by 3 000 ms (a `page.route("**/api/chats/**", handler)` that resolves on `route.fulfill` after a `wait_for_timeout` ONLY for the first matched PUT — a module-level counter; later PUTs pass through untouched), then send the mock question and let the mock turn settle (the done bubble appears in flight — the mock is fast, the upsert is still parked at 3 s). Wait to ~5 s; `GET /api/chats/<id>`: the row has **2** messages (the dropped done-time save re-ran after the parked upsert settled — pre-fix: the row held 1).
|
||||
- `test_share_saves_first_publishes_the_brain_message`: the same 3 s delay on the first PUT; send the mock question; while the send-time save is still parked (the done bubble has appeared), click `#share-chat-btn` (the house click helper, wait for the toast). The share's own upsert PUT passes the route filter UNdelayed (the counter already consumed by the parked send-time save), mints the token, and the toast lands; open `share_url` (the `test_share_chat.py` pattern — the `/shared/<token>` page, the `#shared-title` visible) and assert the shared page renders the BRAIN answer text (a `.msg .bubble` containing the mock answer string) — pre-fix: the shared page showed only the question. Assert the row (via admin `GET`) has 2 messages.
|
||||
- Module docstring: the incident's evidence summary (the 422 PUT log line, the 22-source done frame, `created_at == updated_at` single-write row) so a future reader sees WHY these four tests exist.
|
||||
2. The regression suites (green UNEDITED — do not touch them): `tests/e2e/test_chat_save_payload_limits.py` (oversized-TEXT 422 + small-save 201 — the text caps are unchanged), `tests/e2e/test_chat_history.py`, `tests/e2e/test_share_chat.py`.
|
||||
3. The full gate, in order: `uv run pytest -v` (unit + integration 100% green); `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/`); `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` (**in isolation**); `uv run ruff check . && uv run pyright` clean.
|
||||
4. The docs pass (the phase's Documentation & Consistency mandate): review `README.md` and `AGENTS.md` in their entirety against the post-phase app; fix every inconsistency found, pre-existing ones included. `README.md`: the saved-chat section gains the ops note — a long turn (many cited docs / tool rounds) saves cleanly (the caps now follow the agent's real round cap — 200/100/450), a Share saves the current conversation before minting the token (a failed share upsert aborts — no link to a truncated snapshot), a failed auto-save stays visible until the next success and retries once after 5 s on a transient failure (a 4xx never retries). `AGENTS.md`: verify the phase-55 save contract is not described anywhere in it (the save points are not enumerated — the UI-Structure rule's "in-flight turn" clause is untouched); if the final pass finds any stale save-contract sentence, fix it in place (no new rule added).
|
||||
5. The atomic commit (AGENTS rule 8; the git-commit skill's `--no-gpg-sign` + `LLM-Generated: true` trailer): `git add -A && git commit --no-gpg-sign -m "fix(chats): save long agent turns — realign schema caps, share saves first, dropped saves re-run
|
||||
|
||||
The done-time auto-save of a long turn (production BOR_AGENT_MAX_ROUNDS=50;
|
||||
the incident turn cited 22 docs, 33 tool calls, 34 rounds) 422'd at the
|
||||
phase-83 list caps sized for the round-cap-10 era (sources ≤ 20, tools ≤ 50,
|
||||
segments ≤ 50), and the phase-55 quiet-failure swallowed it: nothing retried,
|
||||
so the row kept its pre-turn state — and a Share minted a token on that stale
|
||||
row, publishing a conversation without its answer (incident 2026-10-16,
|
||||
shared 0101d2ca… / row 8b345ab5…).
|
||||
|
||||
- A1: ChatMessage list caps follow the real configuration — sources/related/
|
||||
tools 200 (4× the production round cap; the cited tier is the read docs,
|
||||
merge_cited_docs), suggestions 100, segments 450; every other bound and the
|
||||
8 M total budget unchanged (SEC-05 boundedness survives).
|
||||
- A2: a linked Share upserts the current conversation before minting the
|
||||
token and aborts on an upsert failure — never a link to a truncated
|
||||
snapshot.
|
||||
- A3: a save point dropped by the in-flight guard re-runs once after the
|
||||
upsert settles.
|
||||
- A4: a failed save's note persists until the next success; one 5 s retry on
|
||||
network/5xx (a 4xx never retries).
|
||||
|
||||
LLM-Generated: true"`.
|
||||
6. Move the phase directory: `mv .agents/phases/todo/189_chat_save_long_turns .agents/phases/complete/` and `git add .agents && git commit --no-gpg-sign -m "chore(agents): complete phase 189 (chat save long turns)"` — OR fold the phase move into the atomic commit per the house convention (check the last 3 phase commits' shape and match it; the AGENTS rule 8 says one atomic commit per phase including the phase files — if the house convention is one commit, `git add` the phase dir into step 5's commit and skip step 6's commit, keeping the `mv`).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the four scenarios above, in isolation.
|
||||
- Regression: the three listed suites green unedited.
|
||||
- Coverage / lint / types: the full gate (step 3).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` green **in isolation** — the incident shape 201/200 at the wire, the mock turn's done-time save lands, the dropped save re-runs (final row 2 messages), the share with in-flight auto-saves publishes the brain message on the shared page.
|
||||
- [ ] `test_chat_save_payload_limits.py`, `test_chat_history.py`, `test_share_chat.py` green UNEDITED.
|
||||
- [ ] `uv run pytest` 100% green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] README.md / AGENTS.md reviewed in full; the saved-chat ops note in place; no stale save-contract sentence left.
|
||||
- [ ] `git diff --stat` (before the commit) limited to `app/schemas.py`, `frontend/assets/app.js`, `tests/unit/test_schemas.py`, `tests/unit/test_save_chat_ui.py`, `tests/integration/test_chats_api.py`, `tests/e2e/test_chat_save_long_turns.py`, `README.md`, the phase files (NO `app/api/chats.py` diff, no migration, no new dependency).
|
||||
- [ ] the atomic `--no-gpg-sign` commit exists (message above); the phase dir is in `.agents/phases/complete/`.
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] no behavior change in completed phases (the SSE `done` event, the `share_chat` endpoint, the unlinked share branch, the phase-55 A2 contract's shape — all unchanged)
|
||||
@@ -0,0 +1,25 @@
|
||||
**Phase 187 — final verification pass: all criteria met, repo 100% green.**
|
||||
|
||||
Verified (all four tasks already in `complete/`)
|
||||
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
|
||||
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
|
||||
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
|
||||
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.
|
||||
|
||||
Gates (exact commands)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
|
||||
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
|
||||
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
|
||||
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)
|
||||
|
||||
Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.
|
||||
|
||||
Documentation
|
||||
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
|
||||
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).
|
||||
|
||||
Notable
|
||||
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
|
||||
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.
|
||||
|
||||
Next pending phase: `188_chunks_hnsw_index`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6932 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Report — phase 187 / task 01 (`app/core/memory.py`)**
|
||||
|
||||
- **Implemented:** `app/core/memory.py` — pure-stdlib (no `app.` imports), mirroring `response_budget.py`'s shape:
|
||||
- `rss_mb()` — parses `VmRSS` (kB→MB floor) from `/proc/self/status`; any `OSError`/`KeyError`/`ValueError`/`IndexError` or missing line falls back to the `getrusage` high-water proxy; `None` only when both are unavailable.
|
||||
- `rss_peak_mb()` — `ru_maxrss // 1024`, guarded `import resource` (`None` on platforms without it), never-raise wrapper around `getrusage`.
|
||||
- `cgroup_peak_mb()` — `/sys/fs/cgroup/memory.peak` bytes→MB, `None` otherwise; docstring carries the one-time container/cgroup caveat (`file` vs `anon`, systemd `3.3G` anecdote, LOCKED A1).
|
||||
- `fmt_mb()` — `"?"` for `None`, `"0"` for 0; module docstring documents the incident, helpers, never-raise contract, "Pure stdlib" line.
|
||||
- **Tests:** `tests/unit/test_core_memory.py` — 24 tests: real Linux `/proc` read (fallback pinned to a sentinel to prove the parse path), kB→MB floor, missing/unparseable/value-less/no-`VmRSS`-file fallbacks, resource stubs, guarded-import branch covered via `importlib.reload` with `sys.modules["resource"]=None` (no pragma — house style), cgroup present/absent/garbage, `fmt_mb`, AST purity pin.
|
||||
- **Gates (all green):**
|
||||
- `uv run pytest tests/unit/test_core_memory.py -v` → 24 passed; `--cov=app.core.memory --cov-branch` → **100%** (0 missed stmts/branches).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4555 passed**, `app/` TOTAL **99%** (>90% gate).
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- `uv run pytest tests/e2e/test_smoke.py --no-cov` → 3 passed (sanity).
|
||||
- **Docs:** no doc impact — nothing user-visible yet (log lines / status keys / README grep land in tasks 02–03, docs pass in task 04).
|
||||
- **Decisions:** guarded `resource` import typed as `ModuleType | None`; path constants (`PROC_STATUS_PATH`, `CGROUP_PEAK_PATH`) patched via `monkeypatch` instead of global `open` mocks; `KeyError` kept in the suppress list per spec with a docstring note that the loop form can't raise it.
|
||||
- **Next pending task:** `187_sync_peak_memory_line/02_sync_log_lines.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
........................................................................ [ 99%]
|
||||
................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 864 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6904 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Implemented (task 02 — `187_sync_peak_memory_line`)**
|
||||
- `app/api/sync.py`: `sync: started … rss_mb=N`; `sync: done detail=… rss_mb=N rss_peak_mb=M`; `sync: cancelled by user … rss_mb=N rss_peak_mb=M`; failed funnel keeps `logger.exception` (traceback) and gains `logger.error("sync: failed state=failed files_done=%d files_total=%d rss_mb=%s rss_peak_mb=%s error=%s")` using the **already-sanitized** `_status.error`.
|
||||
- `app/rag/importer.py`: `ImportSummary.log()` line gains trailing ` rss_mb=%s`; `format_counts()` untouched (CLI consumers byte-identical).
|
||||
- All rendering single-sourced through `app.core.memory.fmt_mb` (`?` for unknown); strictly append-only (LOCKED A2) — module docstrings updated.
|
||||
- Tests: new `test_success_lifecycle_lines_carry_memory_fields`; pins in `test_sync_api.py` (started/done/cancel/failed prefixes byte-identical + `rss_mb=(\?|int)` tails, traceback survives, `error=` masked), `test_importer_e2e.py` (both runs end in ` rss_mb=<int|?>`, earlier fields intact), `tests/unit/test_importer.py` (line prefix pin + `formats=` no longer last), `tests/unit/test_sync_button.py` (cancel-line fullmatch).
|
||||
- Also documented a real nuance found while verifying: on Linux `rss_mb` can print ~1 MB **above** `rss_peak_mb` (kernel high-water accounting + MB flooring) — noted in `app/core/memory.py` + README so nobody "fixes" a correct log line.
|
||||
|
||||
**Gates (all 100% green)**
|
||||
- `uv run pytest` → **4556 passed**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%**, `app/api/sync.py` 100%, `app/core/memory.py` 100% (gate >90%)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
|
||||
- Regression insurance: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → 8 passed (no UI touched; phase E2E belongs to task 04)
|
||||
|
||||
**Docs**: `README.md` — new “Operational notes: the sync’s memory (phase 187)” (greps, field meanings, process-vs-cgroup/page-cache caveat, append-only note) + a bullet in *Sync from the UI*. `AGENTS.md` rule 10 — parenthetical extended with the sync memory fields + `grep ' :: ' | grep ' rss_peak_mb='`. `PLAN.md` §9 (`import: summary … formats=…`) now trails `rss_mb=` — **flagged for the owner to reconcile, not edited**.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/187_sync_peak_memory_line/03_status_key_and_ui.md` (`SyncStatus` fields + sampling, `rss_mb`/`rss_peak_mb` on `/api/sync/status`, `sources.js` ` · peak <N> MB` clause).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 206 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6907 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
All gates are green. Final state:
|
||||
|
||||
**Backend** (`app/api/sync.py`)
|
||||
- `SyncStatus.rss_mb` / `rss_peak_mb` (`int | None`, default `None`, reset with each run) written only through the ONE sampler `_sample_rss(force=False)`: `time.monotonic()` guard makes an unforced call a no-op inside `_RSS_SAMPLE_INTERVAL_S = 5.0`; forced at run start, all three `phase` transitions and every terminal, throttled in the per-file hook.
|
||||
- `GET /api/sync/status` returns both keys additively (null = "no reading", never 0); success / cancelled / failed terminals log exactly the pair they report, so `sync: done …` and the payload are one instant.
|
||||
|
||||
**Frontend** (`frontend/assets/sources.js`)
|
||||
- ONE builder `fmtSyncPeakClause(status)` (`status.rss_peak_mb ?? status.rss_mb`, non-finite/negative → `""`), appended by `setSyncProgress` to the two result strings that already exist (`fmtSyncResult(detail)`, `"Sync cancelled"`) — no new control/element/live region, no clause in running labels, `applySyncFailure`, `applySyncIdle` or the upload settles.
|
||||
|
||||
**Tests** — unit: `_sample_rss` sampling/throttle/force/null, start+phase+terminal sampling, terminal log==payload, key-set pin (old ∪ {`rss_mb`,`rss_peak_mb`}), idle pin, source pins + Node render of the clause. Integration: additive-keys/null-before-run, success + failed terminal pairs (`peak >= rss` on Linux), cancelled pair, anonymous `403` guard. E2E: added `tests/e2e/sync_helpers.py` (`PEAK_CLAUSE`, `counts_line()`, `strip_peak_clause()`) and updated 27 sync-facing suites that asserted exact result-line equality — **documented exact-string update**: the clause is optional trailing text there, and `test_sync_session_reset.py` strips it before its cross-app byte-identity comparison.
|
||||
|
||||
**Gates** — `uv run pytest --cov=app` → EXIT 0, TOTAL 99%, `app/api/sync.py` 100%, `app/core/memory.py` 100%; `ruff check .` clean, `pyright` 0 errors; E2E in isolation (`--no-cov`): `test_sync_summary_visibility` 3 passed, `test_sync_progress_box` 8 passed, `test_sync_status_freshness` 4 passed, `test_sync_button` 3 passed, and the other 23 patched suites all EXIT 0 (390px layout pin still holds in `test_sync_label_mobile`).
|
||||
|
||||
**Docs** — `README.md` (status keys, the ` · peak N MB` clause, sampling/throttle, null renders nothing) and `AGENTS.md` rule 10 + rule 5 UI-structure clause. No git index/history changes; `03_status_key_and_ui.md` left in `todo/` for the harness.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 227 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6928 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
All gates are green. Final state:
|
||||
|
||||
**Implemented (task 04)**
|
||||
- `tests/e2e/test_sync_peak_memory_visible.py` — new mock-only module (own app port 8204 / slow-LLM proxy 8910, dedicated `BOR_SOURCES_DIR`, DB truncated before **and** after each test, admin form login, JS console/page-error guard). 5 tests: success result line `2 added · peak N MB` (wording intact, inside the sole `role=status`/`aria-live` region, no new element, DOM number == payload `rss_peak_mb`, survives reload); payload additive (pre-187 key set + typed `rss_mb`/`rss_peak_mb`, `peak >= current`, kept in the terminal, **no clause while running**); anonymous `GET /api/sync/status` → `403 {"detail":"admin only"}` + no memory text in the shell; cancelled terminal stays neutral `Sync cancelled · peak N MB`; clause fits 375 px with zero horizontal overflow.
|
||||
- Fixture renamed to `memline-fixture/notes/mem-*.md` + a documented naming rule: nothing on the page may contain "peak" except the clause, so the "no clause while running" pin can't be satisfied by a file name.
|
||||
|
||||
**Defect fixed (found by this phase's gates, in task 03's code)** `tests/integration/test_sync_api.py` intermittently failed: `ru_maxrss` refreshes lazily, so a raw peak could sit **below** the current reading (`164` vs `162`). `app/api/sync.py::_sample_rss` (the ONE sampling point) now lifts a lagging peak to the current reading — never fabricating an unknown one — so every surface satisfies `peak >= current`; slack in the integration/E2E pins went 1 MB → 0, two new unit tests pin both branches.
|
||||
|
||||
**Gates** `pytest --cov=app`: **4570 passed**, `TOTAL 99%`, `app/api/sync.py` and `app/core/memory.py` **100%** · `ruff check .` clean · `pyright` 0 errors · target E2E **5 passed in isolation** (+2 repeat runs) · `test_sync_progress_box`, `test_sync_status_freshness`, `test_sync_cancel`, `test_sync_summary_visibility`, `test_sync_button` green in isolation · mutation-checked (drop success clause → tests 1&5 fail; drop cancel clause → test 4 fails; leak clause into the running label → all 5 fail), `sources.js` verified byte-identical to the pre-mutation state.
|
||||
|
||||
**Docs** `README.md` (sync bullet, "States" settle line, *Operational notes: the sync's memory* invariant) and `AGENTS.md` rule 10 now state the `peak >= current` invariant, the terminal-only clause and the new suite; fixed pre-existing page-naming drift (3× "Sources page" → "RAG page (`/sources.html`)"); README/AGENTS path + `BOR_*` default checks report 0 issues.
|
||||
|
||||
**Deviations / flags**
|
||||
1. Per the harness protocol I did **not** commit and did **not** move `todo/187_sync_peak_memory_line/` → `complete/` (tasks 01–03 are already in `complete/`; `00_phase.md` + `04_e2e_peak_memory.md` remain). Everything is staged-free in the working tree for the harness's single commit — suggested message: `feat(observability): report the sync's RSS and peak memory in the log, the status payload and the result line`.
|
||||
2. **PLAN §9 reconciliation (owner action, PLAN.md untouched):** §9 still lists only the per-turn line and the `import: summary` line; phase 187 adds the sync lifecycle memory fields (`rss_mb` on `sync: started` + summary, `rss_mb`/`rss_peak_mb` on all three terminals, plus the two additive `/api/sync/status` keys). §9 needs a matching bullet, or AGENTS rule 10 stays the only place the sync lines are specified.
|
||||
3. **Pre-existing, not from this phase:** running an E2E module and `tests/unit/test_sync_button.py` in the *same* pytest invocation fails (`asyncio.run() cannot be called from a running event loop`) because pytest-playwright's session-scoped loop leaves the main thread marked as running; reproduced with HEAD's unmodified unit file + `test_sync_cancel.py`. The documented gates never mix those paths (`testpaths` excludes `tests/e2e`), so I left it alone rather than touch shared fixtures.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/188_chunks_hnsw_index/01_hnsw_migration.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6932 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -71,14 +71,23 @@
|
||||
settle it), the Copy button's clipboard content equal to the server
|
||||
string exactly, and the shared page's panel Copy button + plain
|
||||
`/document.html` link the ONE owner-directed exception to that page's
|
||||
zero-controls rule) — phase 182 task 07) and
|
||||
meets WCAG 2.1 AA basics (semantic
|
||||
zero-controls rule) — phase 182 task 07), and the sync's peak-memory
|
||||
clause is TRAILING TEXT on the result line that already exists
|
||||
(` · peak <N> MB` on the RAG page's `#sync-progress` counts line
|
||||
or "Sync cancelled", written by the ONE `fmtSyncPeakClause` off
|
||||
`rss_peak_mb ?? rss_mb` — no new control, no new live region, no new
|
||||
element, no clause in a running label or in the failed terminal's
|
||||
empty box, and NOTHING rendered when the pair is null — phase 187
|
||||
task 03) and meets WCAG 2.1 AA basics (semantic
|
||||
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
|
||||
stream).
|
||||
6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically
|
||||
from within the FastAPI application. No `<script src="https://…">` or
|
||||
`<link href="https://…">` tags in HTML templates unless bundled locally.
|
||||
An integration test enforces this on the index page.
|
||||
An integration test enforces this on every served page except
|
||||
`/doc-edit.html` (the parametrized no-CDN check in
|
||||
`tests/integration/test_api.py` — the shell, the viewer, the login and
|
||||
the shared page).
|
||||
7. **"Debugpy Check":** any new entrypoint must keep the conditional import —
|
||||
`debugpy` is imported **only** when `DEBUGPY=1` (see
|
||||
`app/core/debugging.py`); default off, never imported otherwise.
|
||||
@@ -102,7 +111,35 @@
|
||||
`api method=… path=… status=… bytes=… enc=… duration_ms=…` line per
|
||||
`/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it
|
||||
with `grep ' :: api '`, audit it with
|
||||
`uv run python -m scripts.api_payload_audit`, phase 167); UI-side,
|
||||
`uv run python -m scripts.api_payload_audit`, phase 167) **and** the
|
||||
sync's memory fields (phase 187 — `app/core/memory.py` reads PROCESS
|
||||
RSS, never the host's cgroup figure, and never raises: a `None`
|
||||
renders `?` through the ONE `fmt_mb` helper. `rss_mb=` is appended to
|
||||
`sync: started …` and to the importer's `import: summary …`, and
|
||||
`rss_mb=` + `rss_peak_mb=` to all three terminals (`sync: done …`,
|
||||
`sync: cancelled by user …`, and the sanitized one-line
|
||||
`sync: failed state=failed … error=…` that sits beside — never in
|
||||
place of — the `logger.exception` traceback). Append-only: no
|
||||
pre-existing field renamed, reordered or re-typed, so every older
|
||||
grep still matches; read them with
|
||||
`grep ' :: ' server.log | grep ' rss_peak_mb='`. Phase 187 task 03
|
||||
puts the SAME pair on the status: `SyncStatus.rss_mb` /
|
||||
`rss_peak_mb`, written by the ONE `_sample_rss` — forced at the run
|
||||
start, at every `phase` transition and in every terminal (which keep
|
||||
the FINAL pair beside the final counts, the phase-64 convention, and
|
||||
log exactly what the payload reports) — and throttled to one read per
|
||||
`_RSS_SAMPLE_INTERVAL_S` (5 s) from the per-file progress hook, so a
|
||||
27k-file walk costs ~600 `/proc` reads and not 27k (LOCKED A4).
|
||||
`GET /api/sync/status` reports both ADDITIVELY as `rss_mb` /
|
||||
`rss_peak_mb` (`int | null`; `null` is "no reading", never 0) and the
|
||||
failed funnel samples too, because the run that died is the run whose
|
||||
memory matters. That ONE sampler also owns the pair's invariant: the
|
||||
kernel refreshes `ru_maxrss` lazily, so a raw peak can sit BELOW the
|
||||
raw `VmRSS` (measured 164 vs 162) and `_sample_rss` lifts it to the
|
||||
current reading — every surface (both lines, the payload, the page's
|
||||
clause) therefore satisfies `peak >= current`, never a peak below the
|
||||
run's own footprint, and an unknowable peak is never fabricated from
|
||||
the current reading); UI-side,
|
||||
honor the "never stale" feedback contract (PLAN §7.4).
|
||||
11. **Never block the event loop:** anything blocking (a `subprocess`,
|
||||
synchronous file/network I/O, a heavy walk) reached from an `async`
|
||||
|
||||
@@ -622,7 +622,9 @@ only) runs the whole source refresh in one click:
|
||||
the sync area (`role="status"`, polite — phase 150 folded the
|
||||
pre-phase result-line announcer into the box). At settle the box
|
||||
STAYS visible to carry the result line — the counts line
|
||||
("N added · …") after a success, "Sync cancelled" after a cancel —
|
||||
("N added · …") after a success, "Sync cancelled" after a cancel, each
|
||||
trailed by the run's peak memory (" · peak 1936 MB", phase 187; nothing
|
||||
at all when the reading is null) —
|
||||
and the button leaves the running state (the "Synced <stamp>" pill
|
||||
after a success — the date-qualified freshness stamp (phase 154):
|
||||
`HH:MM` on the same local day, `M/D, h:MM AM/PM` otherwise
|
||||
@@ -759,6 +761,32 @@ only) runs the whole source refresh in one click:
|
||||
commit date. Fixing it means changing the quoting grammar — a date
|
||||
change for every non-ASCII document already in a KB, so it is a future
|
||||
phase's call, not a transport phase's.
|
||||
- **The run's memory is a logged number and a line on the page**
|
||||
(phase 187). Every sync lifecycle line ends with `rss_mb=` — and every
|
||||
terminal with `rss_mb= rss_peak_mb=` — read through
|
||||
`app/core/memory.py` (PROCESS RSS, never the host's cgroup figure,
|
||||
which counts page cache; an unknowable reading prints `?`, never
|
||||
`None`), and the SAME pair rides `GET /api/sync/status` as the
|
||||
additive `rss_mb` / `rss_peak_mb` keys (`int | null`). The RAG page
|
||||
(`/sources.html`) appends it to the result line it already spoke —
|
||||
`3 added · 1 unchanged · peak 1936 MB`, `Sync cancelled · peak 1936 MB`
|
||||
— no new control, and nothing at all when the pair is null. "How much
|
||||
RAM did that sync use?" is therefore one grep of the app's own log
|
||||
**or** one
|
||||
glance at the page that started the run, instead of a dig through
|
||||
`journalctl` unit-teardown lines from three boots (the 2026-10-15
|
||||
incident). The fields are appended, so every older grep still matches,
|
||||
and the sampler keeps the pair honest (`peak >= current` — a kernel
|
||||
high-water refreshed lazily is raised to the current reading, because a
|
||||
peak below the run's own footprint is a false number, not a smaller one).
|
||||
Pinned by `tests/unit/test_core_memory.py` (every helper branch, the
|
||||
never-raise contract), `tests/unit/test_sync_button.py` (the sampling
|
||||
points, the 5 s throttle, the pair's invariant),
|
||||
`tests/integration/test_sync_api.py` (the four lifecycle lines, the
|
||||
additive keys, the terminals keeping the final pair, the anonymous 403)
|
||||
and end to end in the browser by
|
||||
`tests/e2e/test_sync_peak_memory_visible.py`. See *Operational notes:
|
||||
the sync's memory (phase 187)*.
|
||||
- **Cancel**: while a run is in flight, a **Cancel** button appears
|
||||
next to the sync button; clicking it posts `POST /api/sync/cancel`
|
||||
(admin only) and registers a cooperative cancel (`202`) — the run
|
||||
@@ -1516,6 +1544,87 @@ under `.agents/phases/`.
|
||||
> overriding it) is correct; unsetting it would hand every client
|
||||
> uncompressed JSON.
|
||||
|
||||
### Operational notes: the sync's memory (phase 187)
|
||||
|
||||
The 2026-10-15 incident made "how much RAM did that sync use?" a dig
|
||||
through `journalctl` unit-teardown lines from three boots — and the boot
|
||||
that killed the box had no teardown record at all, because it never shut
|
||||
down cleanly. Every sync lifecycle line now carries the number, so the
|
||||
question is one grep:
|
||||
|
||||
```
|
||||
2026-10-15 04:12:07 INFO app.api.sync :: sync: done detail={'files': 27411, …} rss_mb=412 rss_peak_mb=1936
|
||||
```
|
||||
|
||||
```bash
|
||||
grep ' :: ' server.log | grep ' rss_peak_mb=' # every sync terminal
|
||||
grep ' :: ' server.log | grep 'sync: started ' # where each run began
|
||||
grep ' :: ' server.log | grep 'sync: failed state=' # the failed ones, with the error
|
||||
```
|
||||
|
||||
- **`rss_mb=`** is the process's CURRENT resident set size in MB (Linux:
|
||||
`VmRSS` from `/proc/self/status`), read at the moment that line is
|
||||
written — the run's start, the import's summary line, and every
|
||||
terminal.
|
||||
- **`rss_peak_mb=`** is the process HIGH-WATER mark
|
||||
(`getrusage(RUSAGE_SELF).ru_maxrss`): the most the process ever held,
|
||||
which is the number the incident was actually about. It appears on the
|
||||
three terminals (`sync: done …`, `sync: cancelled by user …`,
|
||||
`sync: failed state=failed …`) beside the final counts. The two numbers
|
||||
come from different kernel counters and both floor to whole MB, and the
|
||||
kernel refreshes the high-water **lazily**, so a raw `rss_peak_mb()` can
|
||||
sit under a raw `rss_mb()` (measured: 164 against 162). The run's own
|
||||
sampling point (`app.api.sync._sample_rss`) therefore lifts a lagging
|
||||
peak to the current reading before anything publishes it — so on every
|
||||
sync line, on `/api/sync/status` and on the page you always get
|
||||
`peak >= current`. An unknowable peak (`?` / `null`) is never fabricated
|
||||
from the current reading.
|
||||
- **`?` means "unknowable here", never 0 and never `None`** —
|
||||
`app/core/memory.py` never raises, so a platform without `/proc` (or
|
||||
without `resource`) degrades the reading and the sync keeps running.
|
||||
- **Process vs cgroup — know which number you are reading.** These are
|
||||
PROCESS numbers. The host's `/sys/fs/cgroup/memory.peak` — and the
|
||||
`systemd` "… memory peak" line the incident started from — is a
|
||||
different, larger figure, because a cgroup counts **page cache** too:
|
||||
that box's app cgroup showed ~2.16 GB of `file` against ~211 MB of
|
||||
`anon` while idle. Neither figure is wrong; the log says which one it
|
||||
is (LOCKED A1), and `app.core.memory.cgroup_peak_mb()` exists for an
|
||||
operator who wants the other one for context.
|
||||
- **Append-only** (LOCKED A2): the fields trail the existing ones on the
|
||||
pre-phase lines (`sync: started repos=… origin=… git=… local=…`,
|
||||
`sync: done detail=…`, `sync: cancelled by user state=cancelled
|
||||
files_done=… files_total=…`, `import: summary files=… … formats=…`),
|
||||
and the failed funnel keeps its `logger.exception` traceback while
|
||||
gaining ONE sanitized one-line summary (`error=` carries exactly the
|
||||
masked string the status field does). So every grep written before this
|
||||
phase still matches.
|
||||
|
||||
- **On the page, not only in the log.** The same pair rides
|
||||
`GET /api/sync/status` (admin-only, like every sync surface) as the
|
||||
additive `rss_mb` / `rss_peak_mb` keys, and the RAG page
|
||||
(`/sources.html`) appends them to the result line it already speaks —
|
||||
`3 added · 1 unchanged · peak 1936 MB`, or
|
||||
`Sync cancelled · peak 1936 MB`. A terminal KEEPS
|
||||
the final reading next to the final counts, so a page reloaded an hour
|
||||
later still shows what that run reached, and `sync: done …` logs the
|
||||
very same numbers (one reading, two surfaces — the page and the log
|
||||
can never disagree).
|
||||
- **Sampled, never per-file** (LOCKED A4): the run reads its own memory
|
||||
at the run start, at each phase transition (`import` → `overview` →
|
||||
`summaries`), in every terminal, and — throttled to at most one read
|
||||
per 5 s — from the import's per-file progress hook, so the number
|
||||
moves while you watch a long walk without costing 27k `/proc` reads.
|
||||
- **`null` renders NOTHING.** No reading (idle, a run that predates this
|
||||
phase, the upload job's status, a platform that cannot know) shows no
|
||||
clause at all — never `peak ? MB`, never `peak 0 MB`. The failed
|
||||
terminal's box stays empty by the existing contract (the banner and
|
||||
the modal carry the error, byte-identical); a failed run's numbers are
|
||||
in its log line and in the status payload.
|
||||
|
||||
There is deliberately **no** memory audit script and **no** metrics
|
||||
endpoint (LOCKED A3/A2 — no aux services, A12): the grep above is the
|
||||
interface.
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# TODO
|
||||
|
||||
- [ ] Changing the App name in settings broke, it doesn't update the name now.
|
||||
+197
-5
@@ -239,6 +239,32 @@ it (a file with no images must not inherit the previous PDF's
|
||||
position). ASSUMPTION A5: no new ``phase`` value — the image spans
|
||||
live inside ``phase: "import"``.
|
||||
|
||||
Phase 187 (task 02) makes the run's MEMORY a logged number: every
|
||||
lifecycle line gains trailing RSS fields read through
|
||||
:mod:`app.core.memory` — ``sync: started … rss_mb=N`` and, in every
|
||||
terminal, ``… rss_mb=N rss_peak_mb=M`` (the ``import: summary …`` line
|
||||
carries ``rss_mb=`` too, on the importer side). APPEND-ONLY (LOCKED A2):
|
||||
no existing field is renamed, reordered or re-typed, so every pre-phase
|
||||
``grep`` keeps working; an unknowable reading renders ``rss_mb=?``
|
||||
through the ONE :func:`app.core.memory.fmt_mb` helper. The numbers are
|
||||
PROCESS numbers, not the host's cgroup figure — the
|
||||
:mod:`app.core.memory` module docstring owns that caveat. The ``failed``
|
||||
funnel keeps its ``logger.exception`` (the traceback) and gains ONE
|
||||
sanitized one-line summary beside the status field, so the raw exception
|
||||
string never reaches the log un-sanitized.
|
||||
|
||||
Phase 187 (task 03) puts the same numbers on the STATUS surface: the
|
||||
status object carries ``rss_mb`` / ``rss_peak_mb`` (``int | null``,
|
||||
sampled through the ONE :func:`_sample_rss` — at the run start, at every
|
||||
``phase`` transition, in every terminal, and at most once per
|
||||
``_RSS_SAMPLE_INTERVAL_S`` from the per-file progress hook, so a 27k-file
|
||||
walk costs ~600 ``/proc`` reads and not 27k — LOCKED A4), and
|
||||
``GET /api/sync/status`` reports both keys ADDITIVELY. The terminals keep
|
||||
the FINAL reading next to the final counts (the phase-64 convention), so
|
||||
the page that started the run answers the incident's question without a
|
||||
shell. A reading the platform cannot know stays ``null`` on the wire (the
|
||||
UI renders no clause for it) — never 0, never an invented number.
|
||||
|
||||
The ``failed`` state's ``error`` string is masked by the shared
|
||||
sanitizer — the ``user:pass@`` masker now lives in :mod:`app.core.errors`
|
||||
(imported here under the private name ``_sanitize_error``).
|
||||
@@ -247,6 +273,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -257,6 +284,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.config import get_settings
|
||||
from app.core.auth import require_admin
|
||||
from app.core.errors import sanitize_error as _sanitize_error
|
||||
from app.core.memory import fmt_mb, rss_mb, rss_peak_mb
|
||||
from app.db import SessionLocal
|
||||
from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries
|
||||
from app.rag.git_sources import clone_url_for, effective_sources
|
||||
@@ -338,6 +366,21 @@ class SyncStatus:
|
||||
``progress`` hook resets it to 0/0 (a ``progress`` call names the
|
||||
file and clears the image position; a file with no images must
|
||||
not inherit the previous PDF's position).
|
||||
|
||||
Phase 187 (task 03) memory fields: ``rss_mb`` is the process's
|
||||
CURRENT resident set size in MB and ``rss_peak_mb`` its HIGH-WATER
|
||||
mark — both PROCESS readings from :mod:`app.core.memory`, never the
|
||||
host's cgroup figure (that one counts page cache; the caveat lives
|
||||
in that module). They are written by ONE sampler, :func:`_sample_rss`:
|
||||
forced at the run start, at every ``phase`` transition and in every
|
||||
terminal, and throttled to one read per ``_RSS_SAMPLE_INTERVAL_S``
|
||||
from the per-file ``progress`` hook (LOCKED A4 — never one ``/proc``
|
||||
read per file). Both reset to ``None`` with the run — an idle or
|
||||
never-run status reports null, and the Sources page renders no peak
|
||||
clause for null — and every terminal KEEPS the final reading next to
|
||||
the final counts (the phase-64 keep-final-values convention, the one
|
||||
that makes "how much RAM did that sync use?" answerable from the
|
||||
page after the run, not only from the log).
|
||||
"""
|
||||
|
||||
state: Literal["idle", "running", "success", "failed", "cancelled"] = "idle"
|
||||
@@ -372,6 +415,13 @@ class SyncStatus:
|
||||
# the prelude/overview/summaries phases, and in terminal states.
|
||||
images_done: int = 0
|
||||
images_total: int = 0
|
||||
# Phase 187 (task 03): the run's PROCESS memory in MB — current and
|
||||
# high-water (app.core.memory, never the host's cgroup figure). None
|
||||
# idle / never-run and reset with the run; kept as the FINAL reading
|
||||
# in every terminal. Written ONLY through _sample_rss, the one
|
||||
# sampling point (LOCKED A4).
|
||||
rss_mb: int | None = None
|
||||
rss_peak_mb: int | None = None
|
||||
|
||||
|
||||
_status = SyncStatus()
|
||||
@@ -383,6 +433,66 @@ _task: asyncio.Task[None] | None = None
|
||||
# starts, so a stale event can never bleed into the next run.
|
||||
_cancel_event = asyncio.Event()
|
||||
|
||||
#: Phase 187 (task 03, D3/A4): how often the PER-FILE progress hook may
|
||||
#: re-read the process memory, in seconds. That hook fires once per
|
||||
#: file, so without this guard a 27k-file walk would cost 27k
|
||||
#: ``/proc/self/status`` reads; with it the same walk costs ~600 and the
|
||||
#: operator watching the page still sees the number move (PLAN §7.4's
|
||||
#: never-stale contract). Every other sampling point (the run start, the
|
||||
#: ``phase`` transitions, the terminals) FORCES the read instead.
|
||||
_RSS_SAMPLE_INTERVAL_S = 5.0
|
||||
|
||||
#: ``time.monotonic()`` of the last RSS read; ``-inf`` means "never", so
|
||||
#: the first throttled call reads instead of waiting out the interval.
|
||||
#: Module-global like ``_status`` itself (the tests' fresh-state fixture
|
||||
#: re-seeds both).
|
||||
_rss_sampled_at: float = float("-inf")
|
||||
|
||||
|
||||
def _sample_rss(force: bool = False) -> None:
|
||||
"""Read the process memory onto ``_status`` — the ONE sampling point
|
||||
(phase 187, task 03).
|
||||
|
||||
``force=True`` reads NOW: the run start, each ``phase`` transition,
|
||||
every terminal — the points where the number is worth a syscall and
|
||||
where a stale reading would be a lie. ``force=False`` (the per-file
|
||||
progress hook) is a NO-OP inside :data:`_RSS_SAMPLE_INTERVAL_S` of
|
||||
the previous read, which is what makes the sampling throttled rather
|
||||
than per-file (LOCKED A4).
|
||||
|
||||
Both fields come off the SAME read, so the pair the status endpoint
|
||||
and the lifecycle line report is one instant, not two. Nothing here
|
||||
raises: :mod:`app.core.memory` degrades a platform that cannot know
|
||||
to ``None``, which the status renders as JSON null (no UI clause) and
|
||||
the log as ``?``.
|
||||
|
||||
**The pair is normalized to one truth: ``peak >= current``.** The two
|
||||
numbers come from two different kernel counters read at two instants
|
||||
(``VmRSS`` from ``/proc/self/status``, then ``ru_maxrss``), and the
|
||||
kernel refreshes its high-water LAZILY, so a growing process really
|
||||
can report a current reading ABOVE the reported high-water (measured
|
||||
on this repo's own suite: ``rss_mb=164`` against ``rss_peak_mb=162``).
|
||||
A "peak" below the run's own current footprint is not a smaller
|
||||
number, it is a FALSE one — the high-water is by definition at least
|
||||
the current set — so the ONE sampling point raises it to the current
|
||||
reading. That is a lower bound on the truth, never an invention, and
|
||||
it is why every consumer (both lifecycle lines, the status payload,
|
||||
the page's clause) can now take ``peak >= current`` as an invariant
|
||||
instead of carrying a slack. A ``None`` is still a ``None``: an
|
||||
unknowable peak is never fabricated from the current reading.
|
||||
"""
|
||||
global _rss_sampled_at
|
||||
now = time.monotonic()
|
||||
if not force and (now - _rss_sampled_at) < _RSS_SAMPLE_INTERVAL_S:
|
||||
return
|
||||
_rss_sampled_at = now
|
||||
current = rss_mb()
|
||||
peak = rss_peak_mb()
|
||||
if current is not None and peak is not None and peak < current:
|
||||
peak = current # a high-water below the current set is a stale one
|
||||
_status.rss_mb = current
|
||||
_status.rss_peak_mb = peak
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def sync_status() -> dict[str, Any]:
|
||||
@@ -415,8 +525,22 @@ def sync_status() -> dict[str, Any]:
|
||||
file share ONE continuous counter): 0/0 before the first image, in
|
||||
the prelude/overview/summaries phases, and in terminal states; the
|
||||
per-file ``progress`` hook clears it at every file boundary (a
|
||||
file with no images never shows a stale position). Additive only —
|
||||
every pre-existing key keeps its name, type, and meaning.
|
||||
file with no images never shows a stale position).
|
||||
|
||||
Phase 187 (task 03): ``rss_mb`` / ``rss_peak_mb`` are the run's
|
||||
PROCESS memory in MB — the current resident set and the high-water
|
||||
mark (:mod:`app.core.memory`, never the host's cgroup figure, which
|
||||
counts page cache). ``null`` means "no reading", which is the idle /
|
||||
never-run state AND any platform that cannot know — never 0, and
|
||||
never an invented number. The run samples them through
|
||||
:func:`_sample_rss`: at the run start, at every ``phase``
|
||||
transition, throttled to one read per 5 s through the import's
|
||||
per-file hook, and FORCED in every terminal — so a terminal reports
|
||||
the run's FINAL reading next to its final counts (the phase-64
|
||||
convention, same as ``files_done`` / ``files_total``), and a reload
|
||||
after the run still shows the number that ``sync: done …`` logged.
|
||||
Additive only — every pre-existing key keeps its name, type, and
|
||||
meaning.
|
||||
"""
|
||||
return {
|
||||
"state": _status.state,
|
||||
@@ -449,6 +573,14 @@ def sync_status() -> dict[str, Any]:
|
||||
"current_summary": _status.current_summary,
|
||||
"summaries_done": _status.summaries_done,
|
||||
"summaries_total": _status.summaries_total,
|
||||
# Phase 187 (task 03): the run's PROCESS memory in MB (current +
|
||||
# high-water), read through the ONE _sample_rss. null idle /
|
||||
# never-run; in a terminal it is the run's FINAL reading (the
|
||||
# phase-64 keep-final-values convention — the number the
|
||||
# 2026-10-15 incident had to reconstruct from systemd teardown
|
||||
# lines, now on the page that started the run).
|
||||
"rss_mb": _status.rss_mb,
|
||||
"rss_peak_mb": _status.rss_peak_mb,
|
||||
}
|
||||
|
||||
|
||||
@@ -567,6 +699,12 @@ async def _run_sync() -> None:
|
||||
# per-file progress hook re-clears them at every file boundary).
|
||||
_status.images_done = 0
|
||||
_status.images_total = 0
|
||||
# Phase 187 (task 03): the run-start memory sample — forced, so it
|
||||
# BOTH replaces the previous run's readings (a forced sample fully
|
||||
# determines the pair; None where the platform cannot know) and
|
||||
# re-arms the 5 s throttle. No stale peak ever bleeds into a new
|
||||
# run's status.
|
||||
_sample_rss(force=True)
|
||||
try:
|
||||
settings = get_settings()
|
||||
# Step 1 (phase 41): fail fast — verify both models the sync
|
||||
@@ -591,9 +729,15 @@ async def _run_sync() -> None:
|
||||
# legacy directories.
|
||||
raise GitSyncError("no sources configured (git or local)")
|
||||
git_count = sum(1 for row in rows if row.kind == "git")
|
||||
# Phase 187 (task 02): the trailing rss_mb= is APPEND-ONLY (LOCKED
|
||||
# A2) — every pre-phase grep of this line keeps matching. Task 03
|
||||
# logs the run-start SAMPLE (the reading the status reports) so
|
||||
# the line and the payload never disagree — the shape is
|
||||
# byte-identical to task 02's.
|
||||
logger.info(
|
||||
"sync: started repos=%d origin=%s git=%d local=%d",
|
||||
"sync: started repos=%d origin=%s git=%d local=%d rss_mb=%s",
|
||||
len(rows), origin, git_count, len(rows) - git_count,
|
||||
fmt_mb(_status.rss_mb),
|
||||
)
|
||||
sources_root = Path(settings.sources_dir).expanduser()
|
||||
sources: list[Path] = []
|
||||
@@ -690,6 +834,11 @@ async def _run_sync() -> None:
|
||||
# run-start reset alone cannot cover it).
|
||||
_status.images_done = 0
|
||||
_status.images_total = 0
|
||||
# Phase 187 (task 03): the run's memory, sampled THROTTLED
|
||||
# (unforced) — this hook fires once per file, so a 27k-file
|
||||
# walk reads /proc ~600 times instead of 27k (LOCKED A4)
|
||||
# while the number on the page still climbs (PLAN §7.4).
|
||||
_sample_rss()
|
||||
|
||||
# Phase 129 (task 03): the in-flight file's embed-chunk
|
||||
# position — the LLMClient.embed hook (task 01) fires before
|
||||
@@ -720,6 +869,10 @@ async def _run_sync() -> None:
|
||||
# starts NOW (the model-check + clone/pull prelude above
|
||||
# reported ``phase: null``).
|
||||
_status.phase = "import"
|
||||
# Phase 187 (task 03): the phase transitions are the forced
|
||||
# sampling points — each stage of the run begins with its own
|
||||
# reading on the status (the per-file hook throttles).
|
||||
_sample_rss(force=True)
|
||||
# Phase 134 (task 01): the import boundary — a cancel between
|
||||
# the source walk and the import (or parked in a blocking git
|
||||
# span that just finished) lands here, before any file is
|
||||
@@ -744,6 +897,7 @@ async def _run_sync() -> None:
|
||||
overview = False
|
||||
if summary.added + summary.updated > 0:
|
||||
_status.phase = "overview" # phase 98 (task 01)
|
||||
_sample_rss(force=True) # phase 187 (task 03)
|
||||
# Phase 129 (task 03): the import phase ends here — the
|
||||
# chunk position (a sub-position of a done file) clears
|
||||
# with the phase change; the file counts keep their final
|
||||
@@ -803,6 +957,7 @@ async def _run_sync() -> None:
|
||||
# position through the (long) span (its total is the
|
||||
# affected count).
|
||||
_status.phase = "summaries"
|
||||
_sample_rss(force=True) # phase 187 (task 03)
|
||||
# Phase 129 (task 03): the import phase ends here too
|
||||
# (the changed-KB branch skips no phase) — same
|
||||
# chunk-position clear as the overview transition.
|
||||
@@ -828,6 +983,7 @@ async def _run_sync() -> None:
|
||||
# phase + hook (the fill's total is the missing
|
||||
# count — the hook's loop-start ``total``).
|
||||
_status.phase = "summaries"
|
||||
_sample_rss(force=True) # phase 187 (task 03)
|
||||
# Phase 129 (task 03): the import phase ends here
|
||||
# too (the unchanged-walk gap-fill branch) — same
|
||||
# chunk-position clear.
|
||||
@@ -903,7 +1059,20 @@ async def _run_sync() -> None:
|
||||
"overview": overview,
|
||||
"sources_version": sources_version,
|
||||
}
|
||||
logger.info("sync: done detail=%s", _status.detail)
|
||||
# Phase 187 (task 02): the memory the run is at NOW and its
|
||||
# high-water mark, next to the final counts (the phase-64
|
||||
# keep-final-values convention, extended to the numbers the
|
||||
# 2026-10-15 incident had to reconstruct from systemd teardown
|
||||
# lines). Appended after ``detail=`` — the line stays greppable
|
||||
# as ``sync: done detail=``. Task 03 forces the sample FIRST and
|
||||
# logs it, so the payload's rss_mb / rss_peak_mb and this line
|
||||
# carry the SAME final pair (the phase-64 convention, kept
|
||||
# honest across both surfaces).
|
||||
_sample_rss(force=True)
|
||||
logger.info(
|
||||
"sync: done detail=%s rss_mb=%s rss_peak_mb=%s",
|
||||
_status.detail, fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Phase 134 (task 01): the TWO conversion paths (the module
|
||||
# docstring's cancel paragraph). A user-requested cancel —
|
||||
@@ -933,9 +1102,17 @@ async def _run_sync() -> None:
|
||||
# counts above keep their final values).
|
||||
_status.images_done = 0
|
||||
_status.images_total = 0
|
||||
# Phase 187 (task 02): the same two trailing fields as every
|
||||
# other terminal — a cancelled run's memory is still data.
|
||||
# Task 03 forces the sample first, so the cancelled payload
|
||||
# keeps that same final pair (the status object stays
|
||||
# authoritative after a cancel, as everywhere).
|
||||
_sample_rss(force=True)
|
||||
logger.info(
|
||||
"sync: cancelled by user state=cancelled files_done=%d files_total=%d",
|
||||
"sync: cancelled by user state=cancelled files_done=%d files_total=%d"
|
||||
" rss_mb=%s rss_peak_mb=%s",
|
||||
_status.files_done, _status.files_total,
|
||||
fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb),
|
||||
)
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — a background task dies in state, see above
|
||||
@@ -943,6 +1120,21 @@ async def _run_sync() -> None:
|
||||
_status.state = "failed"
|
||||
_status.finished_at = datetime.now(UTC)
|
||||
_status.error = _sanitize_error(str(e))
|
||||
# Phase 187 (task 02): ONE sanitized one-line summary beside the
|
||||
# status field (the SAME masked string, so the log can never leak
|
||||
# credentials the API does not) — the ``logger.exception`` above
|
||||
# stays for the traceback. Task 03 forces the sample first, so
|
||||
# the failed payload carries the same final pair as this line —
|
||||
# a run that died mid-walk is exactly the run whose memory
|
||||
# matters. _sample_rss never raises (app.core.memory never
|
||||
# raises), so a memory read can never mask the failure.
|
||||
_sample_rss(force=True)
|
||||
logger.error(
|
||||
"sync: failed state=failed files_done=%d files_total=%d"
|
||||
" rss_mb=%s rss_peak_mb=%s error=%s",
|
||||
_status.files_done, _status.files_total,
|
||||
fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb), _status.error,
|
||||
)
|
||||
_status.current_file = None # phase 64: keep the final counts
|
||||
_status.phase = None # phase 98: keep the final summary counts
|
||||
_status.current_summary = None
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Process memory as a number — current RSS, peak RSS, and a log formatter
|
||||
(phase 187, task 01, D1, LOCKED A1).
|
||||
|
||||
**Pure stdlib:** this module imports the standard library only — no FastAPI,
|
||||
no ``app.api``, no third-party package — mirroring ``app/core/response_budget.py``.
|
||||
|
||||
**The incident (owner, 2026-10-15).** Reconstructing "how much RAM did the
|
||||
sync actually use?" meant reading ``systemd`` unit-teardown lines from three
|
||||
boots (``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the
|
||||
run that killed the box — had no teardown record at all because the VM never
|
||||
shut down cleanly. The app itself had no opinion about its own memory: no
|
||||
log line, no status field, nothing to ``grep``. These helpers give the sync's
|
||||
lifecycle lines (task 02) a number so the next incident starts from data
|
||||
instead of archaeology.
|
||||
|
||||
**The helpers.**
|
||||
|
||||
* :func:`rss_mb` — the process's CURRENT resident set size in MB. Linux:
|
||||
parse ``VmRSS`` from ``/proc/self/status`` (kB, floored to whole MB). On a
|
||||
platform without ``/proc`` — or on any failure reading it — fall back to
|
||||
``resource.getrusage(RUSAGE_SELF).ru_maxrss // 1024``, which is the
|
||||
process HIGH-WATER mark, not a current value: the best available proxy.
|
||||
(``ru_maxrss`` is kilobytes on Linux, the deploy target.)
|
||||
* :func:`rss_peak_mb` — ``ru_maxrss // 1024``, the process high-water mark.
|
||||
``None`` only where the ``resource`` module is unavailable (Windows); the
|
||||
import is guarded so this module still loads there.
|
||||
* :func:`cgroup_peak_mb` — ``/sys/fs/cgroup/memory.peak`` (cgroup v2, bytes,
|
||||
floored to MB) when that file exists and parses, else ``None``. Read it
|
||||
for context, never as the app's number — see the caveat below.
|
||||
* :func:`fmt_mb` — ``"?"`` for ``None``, else ``str(v)``, so a log line
|
||||
renders ``rss_mb=?`` and never ``rss_mb=None`` (task 02 single-sources
|
||||
the rendering through this one function).
|
||||
|
||||
**The never-raise contract.** Every helper returns ``int`` or ``None``;
|
||||
none may propagate an exception to a caller on the sync or request path.
|
||||
Memory is diagnostics — a failed ``/proc`` read must never fail the turn,
|
||||
the sync, or a status poll that asked for a number, so every read is
|
||||
wrapped and every failure mode degrades to ``None`` (or to the proxy).
|
||||
|
||||
**The container/cgroup caveat (written ONCE, here, so no future reader
|
||||
conflates the numbers).** Inside a container ``/sys/fs/cgroup/memory.peak``
|
||||
is usually absent (the HOST reads it), and the host's number counts PAGE
|
||||
CACHE too: the incident box's app cgroup showed ~2.16 GB of ``file``
|
||||
against ~211 MB of ``anon`` while idle, and ``systemd`` reported
|
||||
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
|
||||
of that. This module therefore reports PROCESS numbers (LOCKED A1); an
|
||||
operator dashboard may legitimately show a larger cgroup number — neither
|
||||
is wrong, and the log says which one it is.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from types import ModuleType
|
||||
|
||||
#: Procfs file carrying ``VmRSS`` (current RSS in kB) among the other
|
||||
#: ``/proc/self`` identity fields. Module-level so tests can point it at a
|
||||
#: stub file (or a nonexistent path) without patching ``open`` globally.
|
||||
PROC_STATUS_PATH = "/proc/self/status"
|
||||
|
||||
#: cgroup v2 high-water mark, in BYTES. Usually absent inside a container —
|
||||
#: see the module docstring's caveat; ``None`` is the normal answer there.
|
||||
CGROUP_PEAK_PATH = "/sys/fs/cgroup/memory.peak"
|
||||
|
||||
#: ``resource`` is Unix-only. Guarded so the module loads (and every helper
|
||||
#: answers ``None`` where it cannot know) on platforms without it.
|
||||
_resource: ModuleType | None
|
||||
try:
|
||||
import resource as _resource
|
||||
except ImportError:
|
||||
_resource = None
|
||||
|
||||
#: The ``/proc/self/status`` label holding current RSS, in kB.
|
||||
_VM_RSS_LABEL = "VmRSS:"
|
||||
|
||||
|
||||
def rss_mb() -> int | None:
|
||||
"""Current process RSS in whole MB, or ``None`` if unknowable.
|
||||
|
||||
Linux path: the ``VmRSS:`` line of ``/proc/self/status`` ("kB" is
|
||||
implied by the kernel; the value is floored with integer division).
|
||||
Any failure — no ``/proc`` (non-Linux), unreadable file, a file
|
||||
without the line, a line whose value is not an integer — falls back
|
||||
to :func:`rss_peak_mb`, the high-water proxy. ``KeyError`` is caught
|
||||
defensively per the phase contract; the line-scanning form below
|
||||
itself cannot raise it (the absent-line case is a fall-through).
|
||||
"""
|
||||
with contextlib.suppress(OSError, KeyError, ValueError, IndexError), open(
|
||||
PROC_STATUS_PATH, encoding="utf-8"
|
||||
) as fh:
|
||||
for line in fh:
|
||||
if line.startswith(_VM_RSS_LABEL):
|
||||
return int(line.split()[1]) // 1024
|
||||
return rss_peak_mb()
|
||||
|
||||
|
||||
def rss_peak_mb() -> int | None:
|
||||
"""Process high-water RSS in whole MB, or ``None`` without ``resource``.
|
||||
|
||||
``ru_maxrss`` is the high-water mark, never a current value (kilobytes
|
||||
on Linux, the deploy target), floored to whole MB. The ``getrusage``
|
||||
call itself is wrapped to honor the never-raise contract even though
|
||||
``RUSAGE_SELF`` cannot realistically fail.
|
||||
|
||||
**One honest edge, measured here (so nobody "fixes" this helper):** the
|
||||
kernel refreshes its high-water LAZILY and the value FLOORs to whole
|
||||
MB, so a growing process can report a CURRENT ``VmRSS`` ABOVE its
|
||||
reported ``ru_maxrss`` — observed on this repo's own suite at
|
||||
``VmRSS`` 217,288 kB against ``ru_maxrss`` 216,832 kB, and again as
|
||||
``rss_mb=164`` against ``rss_peak_mb=162``. This helper stays the RAW
|
||||
kernel reading (it is a diagnostic, and a diagnostic that quietly
|
||||
edits the OS is worse than the imprecision).
|
||||
|
||||
The CALLER that publishes the pair is what normalizes it:
|
||||
:func:`app.api.sync._sample_rss` — the single sampling point behind
|
||||
both sync lifecycle lines, ``GET /api/sync/status`` and the Sources
|
||||
page's clause — lifts a peak that sits below the current reading up to
|
||||
that reading, because a high-water BELOW the process's own current set
|
||||
is not a smaller number but a false one. So the RUN's numbers carry
|
||||
``peak >= current``, while a raw ``rss_peak_mb()`` call may still sit
|
||||
under a raw ``rss_mb()``.
|
||||
"""
|
||||
if _resource is None:
|
||||
return None
|
||||
try:
|
||||
return int(_resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss) // 1024
|
||||
except (OSError, AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cgroup_peak_mb() -> int | None:
|
||||
"""The cgroup v2 ``memory.peak`` in whole MB, or ``None``.
|
||||
|
||||
Context for operators, NOT the app's number: inside a container this
|
||||
file is usually absent (the host reads it), and the host's number
|
||||
counts PAGE CACHE too (~2.16 GB ``file`` against ~211 MB ``anon`` on
|
||||
the idle incident box; ``systemd`` said ``3.3G memory peak`` for the
|
||||
unit while the uvicorn process was a fraction of that). PROCESS
|
||||
numbers are what the sync logs — module docstring has the full
|
||||
caveat, LOCKED A1.
|
||||
"""
|
||||
try:
|
||||
with open(CGROUP_PEAK_PATH, encoding="utf-8") as fh:
|
||||
return int(fh.read().strip()) // (1024 * 1024)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fmt_mb(value: int | None) -> str:
|
||||
"""Render an MB reading for a log line: ``?`` when unknown, digits when not.
|
||||
|
||||
``0`` is a VALUE, not missing — it renders ``"0"``. This is the single
|
||||
place the ``rss_mb=?`` convention lives (task 02 formats every line
|
||||
through here).
|
||||
"""
|
||||
return "?" if value is None else str(value)
|
||||
+10
-1
@@ -357,6 +357,7 @@ from sqlalchemy import Select, select
|
||||
from sqlalchemy.orm import Session, defer, load_only
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.memory import fmt_mb, rss_mb
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.chunker import chunk_document, extract_title
|
||||
@@ -532,7 +533,7 @@ class ImportSummary:
|
||||
"summary_backfilled=%d dates_updated=%d images_failed=%d svg_indexed=%d "
|
||||
"image_context_backfilled=%d pdfs_screenshotted=%d "
|
||||
"pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d "
|
||||
"pdf_images_blank_skipped=%d formats=%s",
|
||||
"pdf_images_blank_skipped=%d formats=%s rss_mb=%s",
|
||||
self.files,
|
||||
self.added,
|
||||
self.updated,
|
||||
@@ -554,6 +555,14 @@ class ImportSummary:
|
||||
self.pdf_images_deduped,
|
||||
self.pdf_images_blank_skipped,
|
||||
self.format_counts(),
|
||||
# Phase 187 (task 02): the run's CURRENT process RSS on the
|
||||
# cron-safe summary line (PLAN §9) — appended, so the
|
||||
# pre-phase line stays greppable field-for-field. The
|
||||
# high-water mark belongs to the SYNC TERMINALS
|
||||
# (app/api/sync.py), not to this line; ``format_counts()``
|
||||
# is deliberately untouched, so every other consumer of it
|
||||
# is byte-identical.
|
||||
fmt_mb(rss_mb()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -457,6 +457,21 @@
|
||||
* rules stop walking a full tree — folderKnownIn() only claims what
|
||||
* the HELD levels prove — and treeStats() prefers kbTree.totals (the
|
||||
* walk is the pre-phase-167 fallback only).
|
||||
*
|
||||
* Phase 187 (task 03, D4/A4) — the sync result line carries the run's
|
||||
* PEAK MEMORY. The two additive `rss_mb` / `rss_peak_mb` keys on
|
||||
* GET /api/sync/status feed ONE clause builder (fmtSyncPeakClause),
|
||||
* which APPENDS ` · peak 512 MB` to the result text the settle already
|
||||
* wrote (the fmtSyncResult counts line, "Sync cancelled") — no new
|
||||
* control, no new state string, no new live region, and the failed
|
||||
* terminal's empty box is untouched (the banner + modal keep the error
|
||||
* copy byte-identical, phase 150). Null — idle, a run that predates the
|
||||
* phase, a platform that cannot know, and the upload job (whose status
|
||||
* has no memory keys at all) — renders NOTHING, never "peak ? MB". The
|
||||
* clause rides the single box writer, so the sole aria-live region
|
||||
* announces today's wording plus the clause, and the number is the
|
||||
* PROCESS high-water mark app/core/memory.py reports (never the host's
|
||||
* cgroup figure — that one counts page cache).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -1103,6 +1118,37 @@ export async function mount(root) {
|
||||
return { main, count, chunk, image };
|
||||
}
|
||||
|
||||
/* Phase 187 (task 03, D4/A4): the run's PEAK-memory clause — the one
|
||||
* number the 2026-10-15 incident had to reconstruct from three boots
|
||||
* of systemd teardown lines, now on the page that started the run.
|
||||
* It is a CLAUSE on the result line that already exists (the
|
||||
* fmtSyncResult counts line, "Sync cancelled"), never a new control,
|
||||
* never a state string of its own, and never a replacement: the
|
||||
* existing copy keeps its exact wording and the clause trails it
|
||||
* (" · peak 512 MB"). The value is the status's rss_peak_mb (the
|
||||
* process high-water mark, the number the incident was about) or,
|
||||
* where only a current reading exists, rss_mb — both PROCESS numbers
|
||||
* from app/core/memory.py, never the host's cgroup figure (that one
|
||||
* counts page cache; the caveat owns app/core/memory.py).
|
||||
* NULL IS THE ABSENCE CONTRACT: both keys null (idle, a run predating
|
||||
* this phase, a platform that cannot know) renders NOTHING — never
|
||||
* "peak ? MB", never "peak null MB", never 0. The clause rides the
|
||||
* SAME string the single box writer (setSyncProgress) lands on
|
||||
* #sync-progress-text, so the box — the sole sync text surface AND
|
||||
* the sole aria-live region (phase 150) — announces today's wording
|
||||
* plus the clause, with no second announcement and no new region. It
|
||||
* appears in the TERMINAL renders only (success + cancelled): the
|
||||
* running label keeps its byte-identical shape, the failed terminal's
|
||||
* box stays empty by the phase-150 contract (the banner + modal carry
|
||||
* the error, byte-identical), and the upload settle never has the keys
|
||||
* at all — which is the null case again. Text wraps inside the
|
||||
* existing line (no clip, no .cell-clip, no layout shift at 375px). */
|
||||
function fmtSyncPeakClause(status) {
|
||||
const peak = status ? status.rss_peak_mb ?? status.rss_mb : null;
|
||||
if (typeof peak !== "number" || !Number.isFinite(peak) || peak < 0) return "";
|
||||
return ` · peak ${peak} MB`;
|
||||
}
|
||||
|
||||
function fmtSyncResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
@@ -1284,8 +1330,11 @@ export async function mount(root) {
|
||||
// Phase 150 (task 02): the settle's counts line IS the box's
|
||||
// result line (the sole live region) — the string builder is
|
||||
// unchanged (A2: the "Synced <stamp>" label stays on the button;
|
||||
// the stamp is date-qualified, phase 154).
|
||||
setSyncProgress(fmtSyncResult(status.detail));
|
||||
// the stamp is date-qualified, phase 154). Phase 187 (task 03)
|
||||
// APPENDS the peak-memory clause to that same string — the counts
|
||||
// line keeps its exact wording, the clause trails it, and a null
|
||||
// peak contributes nothing (fmtSyncPeakClause).
|
||||
setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status));
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
// Refresh the catalog live — the KB just changed.
|
||||
@@ -1322,7 +1371,10 @@ export async function mount(root) {
|
||||
* else. */
|
||||
function applySyncCancelled(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
setSyncProgress("Sync cancelled"); // the box's result line (phase 150)
|
||||
// The box's result line (phase 150) + the phase-187 peak clause —
|
||||
// a cancelled run's memory is still the number the operator asked
|
||||
// for ("Sync cancelled · peak 512 MB", nothing when it is null).
|
||||
setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status));
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Shared sync-result-line helpers (phase 187, task 03).
|
||||
|
||||
The Sources page's sync result line — the ``fmtSyncResult`` counts line
|
||||
and the neutral ``"Sync cancelled"`` line the ``#sync-progress`` box
|
||||
speaks — now trails the run's PEAK-MEMORY clause (`` · peak 1936 MB``),
|
||||
sourced from ``GET /api/sync/status``'s additive ``rss_peak_mb`` /
|
||||
``rss_mb`` pair (the 2026-10-15 incident: "how much RAM did that sync
|
||||
use?" used to mean reading ``systemd`` unit-teardown lines off three
|
||||
boots; now it is on the page that started the run and in the log).
|
||||
|
||||
The clause is per-run DATA, so the suites that pin the OWNER-VISIBLE
|
||||
counts pin the counts plus an OPTIONAL clause:
|
||||
|
||||
* :func:`counts_line` — a Playwright-ready pattern: the exact expected
|
||||
line, with `` · peak <N> MB`` allowed at the end (and the whole line
|
||||
anchored, so a mutated counts line still fails);
|
||||
* :func:`strip_peak_clause` — for the suites that compare two runs' or
|
||||
two apps' lines byte-for-byte: the memory number belongs to the
|
||||
process, not to the KB, so it is not part of a byte-identity contract.
|
||||
|
||||
A NULL pair renders NOTHING (``fmtSyncPeakClause``), which is why the
|
||||
clause is optional rather than required: an idle app, a status that
|
||||
predates phase 187, and the upload job all settle without it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: The clause sources.js appends (``fmtSyncPeakClause``), as a regex —
|
||||
#: optional, because a null peak/current pair appends the empty string.
|
||||
PEAK_CLAUSE = r"(?: · peak \d+ MB)?"
|
||||
|
||||
#: The same clause, for a substitution (``strip_peak_clause``): anchored
|
||||
#: at end of line, so nothing else is ever stripped.
|
||||
_PEAK_CLAUSE_TAIL = re.compile(r" · peak \d+ MB$")
|
||||
|
||||
|
||||
def counts_line(expected: str | re.Pattern[str]) -> re.Pattern[str]:
|
||||
"""Match the EXACT ``expected`` sync result line, clause included or
|
||||
not — the pin a suite writes when it cares about the counts, not
|
||||
about the number of megabytes the run happened to reach.
|
||||
|
||||
``expected`` is either the literal line (escaped, so the ``·`` and
|
||||
the digits mean exactly what they say) or a suite's own shape
|
||||
pattern (its anchors are dropped and re-applied around the optional
|
||||
clause, so ``^3 added$`` becomes ``^3 added(?: · peak \\d+ MB)?$``).
|
||||
"""
|
||||
if isinstance(expected, re.Pattern):
|
||||
body = expected.pattern
|
||||
if body.startswith("^"):
|
||||
body = body[1:]
|
||||
if body.endswith("$"):
|
||||
body = body[:-1]
|
||||
return re.compile(rf"^{body}{PEAK_CLAUSE}$")
|
||||
return re.compile(rf"^{re.escape(expected)}{PEAK_CLAUSE}$")
|
||||
|
||||
|
||||
def strip_peak_clause(text: str) -> str:
|
||||
"""``text`` without its trailing peak clause (if any) — the
|
||||
byte-identity comparison's view of the line."""
|
||||
return _PEAK_CLAUSE_TAIL.sub("", text)
|
||||
@@ -90,6 +90,7 @@ from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
@@ -381,7 +382,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _seed_image_doc(
|
||||
|
||||
@@ -102,6 +102,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -447,7 +448,7 @@ def _expect_counts_line(page: Page, expected: str) -> None:
|
||||
"""The counts line — the owner-visible sync report on the single sync
|
||||
text surface (phase 150; sources.js fmtSyncResult; the zero "updated"
|
||||
part is omitted)."""
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
|
||||
|
||||
|
||||
def _goto_sources(page: Page, base_url: str) -> None:
|
||||
|
||||
@@ -104,6 +104,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -483,7 +484,7 @@ def test_sync_survives_a_force_push(
|
||||
assert body["state"] == "success", body
|
||||
# The fixture doc (one A9-format file) is the only change — the
|
||||
# counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the drill-down tree, the test_sync_button pattern).
|
||||
_expect_fixture_row(page)
|
||||
@@ -511,7 +512,7 @@ def test_sync_survives_a_force_push(
|
||||
# …and the digest delta re-indexed the file as updated (the counts
|
||||
# are the secondary signal — the content assertion below is
|
||||
# primary). The counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 updated")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 updated"))
|
||||
# The KB holds the NEW head: the document modal serves the V2
|
||||
# sentinel — the reset's content flowed through the import.
|
||||
_expect_fixture_row(page)
|
||||
@@ -533,7 +534,7 @@ def test_sync_survives_a_force_push(
|
||||
# "1 unchanged" means the content hash did not move — drift back
|
||||
# to V1 would have re-reported as "1 updated" (the no-drift pin).
|
||||
# The counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -137,6 +137,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
from scripts.git_sync import file_commit_dates
|
||||
from scripts.import_docs import repo_name
|
||||
|
||||
@@ -488,7 +489,7 @@ def _run_sync(page: Page, expected_line: str | re.Pattern[str]) -> dict[str, Any
|
||||
expect(btn).to_be_enabled() # never stale at the terminal state
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
expected_line, timeout=UI_SETTLE_TIMEOUT_MS
|
||||
counts_line(expected_line), timeout=UI_SETTLE_TIMEOUT_MS
|
||||
)
|
||||
return dict(body["detail"] or {})
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -515,7 +516,7 @@ def test_add_with_branch_syncs_that_branch(
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# Both branch docs are fresh after the per-test truncate — the
|
||||
# counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
|
||||
|
||||
# --- the branch's content is what got indexed ----------------------
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
@@ -539,7 +540,7 @@ def test_add_with_branch_syncs_that_branch(
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# Nothing re-embeds (sha256 delta) — the no-op run announces the
|
||||
# unchanged count (release's two files) on the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 2 unchanged")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 2 unchanged"))
|
||||
# The branch's doc survived the prune re-import.
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
|
||||
@@ -577,7 +578,7 @@ def test_default_branch_source_unaffected(
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# The default branch's two docs are fresh — the counts line rides
|
||||
# the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
|
||||
|
||||
# The default branch (main) is what landed — the pre-phase
|
||||
# byte-identical guarantee, observable through the UI…
|
||||
@@ -684,7 +685,7 @@ def test_row_editor_branch_change_fails_loud(
|
||||
# The KB is still empty (the failed sync aborted before the
|
||||
# import) — the restored branch's two docs are fresh additions;
|
||||
# the counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
|
||||
# And the branch's doc is what is indexed again.
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -330,7 +331,7 @@ def _sync_in_app(page: Page, app_url: str) -> None:
|
||||
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
||||
# Both fixture docs are fresh after the per-test truncate — the
|
||||
# counts line rides the box (phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
|
||||
|
||||
|
||||
def _source_node(tree_json: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.models import Chunk, Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
@@ -342,7 +343,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _captured_systems(mock_llm: int) -> list[str]:
|
||||
|
||||
@@ -51,6 +51,7 @@ from app.rag.agent import IMAGE_DOC_MARKER
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
@@ -280,7 +281,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _drill_to_source(page: Page, name: str) -> None:
|
||||
|
||||
@@ -70,6 +70,7 @@ from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -366,7 +367,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _ask(page: Page, app_url: str, question: str) -> None:
|
||||
|
||||
@@ -88,6 +88,7 @@ from app.rag.pdf import is_blank_image
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE_ON = "e2e-pdf151" # the story app's archive stem (archive_source_name)
|
||||
@@ -427,7 +428,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _drill_to_source(page: Page, name: str) -> None:
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.models import Document
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE_NAME = "e2e-pdf" # the archive stem (archive_source_name)
|
||||
@@ -324,7 +325,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _drill_to_source(page: Page, name: str) -> None:
|
||||
|
||||
@@ -147,6 +147,7 @@ from app.rag.pdf import (
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import PICTORIAL_PAGE_ANSWER
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name)
|
||||
@@ -479,7 +480,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
|
||||
|
||||
|
||||
def _drill_to_source(page: Page, name: str) -> None:
|
||||
|
||||
@@ -101,6 +101,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -403,7 +404,7 @@ def _sync_from_sources_page(page: Page, app_url: str) -> dict[str, Any]:
|
||||
def _expect_counts_line(page: Page, expected: str) -> None:
|
||||
"""The Sources page's counts line reads exactly ``expected`` —
|
||||
"9 added" / "0 added · 3 unchanged · 6 pruned"."""
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
|
||||
|
||||
|
||||
def _add_local_source(page: Page, app_url: str, path: Path) -> str:
|
||||
|
||||
@@ -52,6 +52,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -273,7 +274,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# The fixture doc (one A9-format file) is the only change — the
|
||||
# settle's counts line rides the box (phase 150: the box is the
|
||||
# sole sync text surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
|
||||
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the sentinel lives inside it) — via the drill-down tree
|
||||
@@ -297,7 +298,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
||||
# unchanged count instead of an empty live region (the box keeps
|
||||
# its result line — phase 150).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
|
||||
# The doc survived the prune re-import (its file is still in the
|
||||
# repo) — via the drill-down tree (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
@@ -93,6 +93,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -135,8 +136,9 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
|
||||
#: The settled SUCCESS result line — "N added" or "N added · M
|
||||
#: unchanged" (sources.js's fmtSyncResult — updated/pruned are zero on
|
||||
#: the re-sync: the repo is unchanged and nothing was pruned).
|
||||
RESULT_LINE = re.compile(r"^(\d+) added(?: · (\d+) unchanged)?$")
|
||||
#: the re-sync: the repo is unchanged and nothing was pruned) with the
|
||||
#: phase-187 peak clause optional at the end (null pair → no clause).
|
||||
RESULT_LINE = re.compile(rf"^(\d+) added(?: · (\d+) unchanged)?{PEAK_CLAUSE}$")
|
||||
|
||||
#: Settle budgets: the cancel terminal lands ~1 s after the click and
|
||||
#: the UI's 2 s poll settles at most one tick after the terminal
|
||||
@@ -547,7 +549,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
|
||||
# Phase 150: the neutral result line rides the box (the sole sync
|
||||
# text surface — the retired announcer span is gone).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
"Sync cancelled", timeout=SYNC_SETTLE_TIMEOUT_MS
|
||||
counts_line("Sync cancelled"), timeout=SYNC_SETTLE_TIMEOUT_MS
|
||||
)
|
||||
# A1: the cancel is never rendered as a failure — no error banner,
|
||||
# no error modal in the DOM.
|
||||
|
||||
@@ -69,6 +69,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -267,7 +268,7 @@ def _expect_settle_line(page: Page, expected: str) -> None:
|
||||
Phase 150: the box IS the result line's surface (the retired
|
||||
announcer span is gone)."""
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
expected, timeout=SYNC_TIMEOUT_MS
|
||||
counts_line(expected), timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -948,5 +949,5 @@ def test_label_shows_the_chunk_part(
|
||||
expect(page.locator(".sync-label-chunk")).to_be_hidden()
|
||||
expect(page.locator(".sync-label-chunk")).to_have_text("")
|
||||
# The counts line rides the box (phase 150 — the sole sync surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
@@ -129,6 +129,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
|
||||
from scripts.import_docs import repo_name
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
@@ -187,9 +188,10 @@ RUN_SETTLE_TIMEOUT_MS = 90_000
|
||||
UI_SETTLE_TIMEOUT_MS = 15_000
|
||||
|
||||
#: "Synced HH:MM" (``sources.js`` ``fmtSyncTime``) and the settled
|
||||
#: SUCCESS result line (``fmtSyncResult``) — updated/pruned are 0 here.
|
||||
#: SUCCESS result line (``fmtSyncResult``) — updated/pruned are 0 here,
|
||||
#: and the phase-187 peak clause is optional (null pair → no clause).
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
RESULT_LINE = re.compile(r"^(\d+) added(?: · (\d+) unchanged)?$")
|
||||
RESULT_LINE = re.compile(rf"^(\d+) added(?: · (\d+) unchanged)?{PEAK_CLAUSE}$")
|
||||
|
||||
#: The console noise the anonymous probes legitimately produce (this
|
||||
#: module's own tests are admin — the collector stays as the uncaught-JS
|
||||
@@ -616,7 +618,7 @@ def test_status_polls_responsive_during_long_clone(
|
||||
|
||||
# And it stayed in its live state: no error banner, no modal, the
|
||||
# neutral running copy, the Cancel control still there.
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("Syncing…"))
|
||||
expect(btn).to_be_disabled()
|
||||
expect(cancel).to_be_visible()
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
@@ -784,7 +786,7 @@ def test_cancel_during_clone_reaches_terminal(
|
||||
|
||||
# The neutral UI settle: "Sync cancelled", retry-ready, no error.
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
"Sync cancelled", timeout=UI_SETTLE_TIMEOUT_MS
|
||||
counts_line("Sync cancelled"), timeout=UI_SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
"Sync sources", timeout=UI_SETTLE_TIMEOUT_MS
|
||||
|
||||
@@ -141,6 +141,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -710,13 +711,15 @@ def test_label_advances_through_the_image_span(
|
||||
if snap["box"] != last:
|
||||
seen.append(snap["box"])
|
||||
last = snap["box"]
|
||||
if snap["box"] == SETTLE_TEXT:
|
||||
if counts_line(SETTLE_TEXT).fullmatch(snap["box"]):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
# --- the settle (the UI's 2 s poll renders the terminal at most one
|
||||
# tick after it lands; the endpoint is the source of truth) ---
|
||||
assert last == SETTLE_TEXT, f"the run never settled to the counts line: {seen[-5:]}"
|
||||
assert counts_line(SETTLE_TEXT).fullmatch(str(last)), (
|
||||
f"the run never settled to the counts line: {seen[-5:]}"
|
||||
)
|
||||
terminal = _wait_terminal(app_url)
|
||||
# The counts line's ground truth: the PDF + its 4 embedded-image
|
||||
# docs — one file, 5 added.
|
||||
@@ -806,7 +809,7 @@ def test_label_advances_through_the_image_span(
|
||||
# surface): the image part is not in it, not in the title, not in
|
||||
# the aria (the settle restores the ship aria value).
|
||||
box = page.locator("#sync-progress-text").inner_text()
|
||||
assert box == SETTLE_TEXT, box
|
||||
assert counts_line(SETTLE_TEXT).fullmatch(box), box
|
||||
assert "· image" not in box and "· image" not in (btn.get_attribute("title") or "")
|
||||
assert btn.get_attribute("aria-label") == "Sync sources", btn.get_attribute("aria-label")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
@@ -869,7 +872,7 @@ def test_mid_run_reload_re_attaches_with_image_position(
|
||||
# terminal clears the position, test 1 pins the endpoint half),
|
||||
# the terminal label, the parts cleared + empty.
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
SETTLE_TEXT, timeout=SETTLE_TIMEOUT_MS
|
||||
counts_line(SETTLE_TEXT), timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||
btn = page.locator("#sync-btn")
|
||||
|
||||
@@ -157,6 +157,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -688,7 +689,7 @@ def _settle(page: Page, app_url: str) -> None:
|
||||
# retired announcer spoke, byte-identical: the fresh 4-file
|
||||
# fixture's "4 added"), hiding only when its text is empty.
|
||||
expect(page.locator("#sync-progress")).to_be_visible()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -454,7 +455,7 @@ def test_healthy_sync_still_succeeds(
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
||||
# The counts line rides the box (phase 150 — the sole sync surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
|
||||
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the sentinel lives inside it) — via the drill-down tree
|
||||
@@ -475,7 +476,7 @@ def test_healthy_sync_still_succeeds(
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
|
||||
# The doc survived the prune re-import — via the drill-down tree
|
||||
# (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
"""Phase 187 (task 04) E2E (Playwright, mock-only): the sync's PEAK MEMORY
|
||||
is visible on the page that started the run — and it is the truth.
|
||||
|
||||
Source: owner incident 2026-10-15. Reconstructing "how much RAM did that
|
||||
sync use?" meant reading ``systemd`` unit-teardown lines from three boots
|
||||
(``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the run
|
||||
that killed the box — had NO teardown record at all, because the VM never
|
||||
shut down cleanly. The app had no opinion about its own memory: no log
|
||||
line, no status field, nothing on the Sources page. Phase 187 made it a
|
||||
number (``app/core/memory.py``, the lifecycle lines, the status keys) and
|
||||
this module is the browser proof of the last mile.
|
||||
|
||||
What the phase put on the wire, in the operator's words:
|
||||
|
||||
* ``GET /api/sync/status`` carries the ADDITIVE pair ``rss_mb`` (the
|
||||
process's CURRENT resident set, MB) and ``rss_peak_mb`` (its HIGH-WATER
|
||||
mark) — ``int | null``, where ``null`` means "no reading" and NEVER 0;
|
||||
sampled at the run start, at every ``phase`` transition, throttled to
|
||||
one read per 5 s through the import's per-file hook, and FORCED in every
|
||||
terminal, which KEEPS the final pair next to the final counts;
|
||||
* the Sources page appends `` · peak <N> MB`` to the result line it
|
||||
already spoke (the ``fmtSyncResult`` counts line, "Sync cancelled") —
|
||||
trailing text on a line that already exists, never a new control, never
|
||||
a new live region, and NOTHING rendered when the pair is null.
|
||||
|
||||
**Which number this is (read it before comparing it to a dashboard).**
|
||||
Both fields are PROCESS readings — ``VmRSS`` from ``/proc/self/status`` and
|
||||
``getrusage(RUSAGE_SELF).ru_maxrss`` — NOT the host's cgroup figure. A
|
||||
cgroup counts PAGE CACHE too: the incident box's app cgroup showed ~2.16 GB
|
||||
of ``file`` against ~211 MB of ``anon`` while idle, and ``systemd`` printed
|
||||
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
|
||||
of that. Neither number is wrong; LOCKED A1 pins the app to the process
|
||||
one, and `README.md` (*Operational notes: the sync's memory (phase 187)*)
|
||||
owns the full caveat and the operator grep
|
||||
(``grep ' :: ' server.log | grep ' rss_peak_mb='``). This module never
|
||||
asserts a MAGNITUDE — only that the number is present, typed, terminal,
|
||||
honest (``peak >= current``, with the one exception the sampler owns: a
|
||||
kernel high-water refreshed lazily is lifted to the current reading, never
|
||||
invented from it), and equal on both surfaces.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the deterministic
|
||||
mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy is what makes
|
||||
the RUNNING observation a fact instead of a race: the run must outlive the
|
||||
UI's 2 s status poll (and the tests' ~100 ms one) so the "no clause while
|
||||
running" pin and the cancel window are both deterministic. Two files →
|
||||
≈ 10 LLM calls (the two model probes, the per-file embed batches + the
|
||||
summaries, the KB overview, the source-root folder summary) ≈ 5 s at
|
||||
``SLOW_DELAY_S = 0.5``.
|
||||
|
||||
Isolation (the house shape): ONE module app on its own port (the conftest
|
||||
session app owns 8123 in a combined run), the mock LLM on the conftest
|
||||
port behind the proxy, a dedicated ``BOR_SOURCES_DIR`` /
|
||||
``BOR_PDF_PAGE_DIR`` so no run writes into the operator's, the shared E2E
|
||||
Postgres truncated BEFORE **and** AFTER every test (after waiting for the
|
||||
app to hold no running sync), and the admin in through the real form login
|
||||
(``tests/e2e/auth_helpers.py``). Each test seeds the same two-file
|
||||
``kind=local`` source directory and registers it through the authenticated
|
||||
API, so any single test re-runs alone with a fresh KB and the counts line
|
||||
says ``2 added``.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
|
||||
the observable; the DOM number and the payload number are read as one
|
||||
comparison, so "the page lies" cannot slip through):
|
||||
|
||||
1. ``test_result_line_shows_the_peak`` — a full run from the Sources page:
|
||||
the box (``#sync-progress-text``) settles the counts line WITH the
|
||||
trailing clause (``^2 added · peak \\d+ MB$``), the clause matches
|
||||
``/peak \\d+ MB/``, the aria-live region IS the box (``role=status`` +
|
||||
``aria-live=polite``, no ``aria-hidden``) and carries the SAME text,
|
||||
the clause is TEXT and not an element (the box still holds exactly one
|
||||
child span — no new control), and the megabytes in the DOM EQUAL the
|
||||
terminal payload's ``rss_peak_mb``. Where the platform cannot know the
|
||||
pair, the mirror contract is pinned instead: NO clause at all.
|
||||
2. ``test_status_payload_reports_rss`` — the endpoint end to end: a tick
|
||||
during ``running`` (a) reports the pair with the documented types
|
||||
(``int | null``), (b) carries the pre-phase key set UNCHANGED (the
|
||||
additive pin: exactly the pre-187 keys plus the memory pair) and (c)
|
||||
shows NO ``peak`` clause on the page — the clause is terminal-only;
|
||||
after the terminal ``rss_peak_mb`` is a POSITIVE int with
|
||||
``peak >= current`` (the invariant the ONE sampler owns: the kernel
|
||||
refreshes its high-water lazily, so ``_sample_rss`` lifts a lagging peak
|
||||
to the current reading — a peak under the run's own footprint would be a
|
||||
false number), and the same pair survives a later poll (the page
|
||||
reloaded an hour later still answers).
|
||||
3. ``test_anonymous_cannot_read_memory`` — the A4 exposure guard: a
|
||||
logged-out ``GET /api/sync/status`` is 403 with the pre-phase body
|
||||
``{"detail": "admin only"}`` — no memory number on an anonymous
|
||||
surface, before or after a run — and the page shows the sign-in gate
|
||||
with no sync button and no peak clause anywhere in the DOM.
|
||||
4. ``test_cancelled_terminal_keeps_the_peak`` — the ``test_sync_cancel.py``
|
||||
shape: cancel mid-run and the NEUTRAL "Sync cancelled" state still
|
||||
renders (no banner, no modal, the button retry-ready) with the clause
|
||||
present and equal to the cancelled payload's ``rss_peak_mb`` — the
|
||||
run that stopped early is exactly the run whose memory matters.
|
||||
5. ``test_peak_clause_fits_at_375`` — PLAN §7 at phone width: after a run
|
||||
settled at 375 px, the clause is on screen, the box does not clip it
|
||||
(``scrollWidth <= clientWidth`` for the box and its text), the box
|
||||
stays inside the viewport and the PAGE has no horizontal overflow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, ConsoleMessage, Page, ViewportSize, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings as _Settings
|
||||
from app.core.memory import rss_mb, rss_peak_mb
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
MOCK_PORT,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: Dedicated module ports (the house env-overridable pattern): the conftest
|
||||
#: session app owns 8123 and the mock owns 8901 in a combined run.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_PEAKMEM", "8204"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The slow-LLM proxy's port (the conftest's mock stays on MOCK_PORT — the
|
||||
#: proxy fronts it).
|
||||
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_PEAKMEM", "8910"))
|
||||
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||
|
||||
#: Per-LLM-request delay (the phase-64 timing convention): the run spans
|
||||
#: ≈ 10 calls ≈ 5 s, i.e. ≫ the UI's 2 s status poll — the running window
|
||||
#: and the cancel window are both observable, not races.
|
||||
SLOW_DELAY_S = "0.5"
|
||||
|
||||
SOURCES_URL = "/sources.html"
|
||||
|
||||
#: The fixture directory's basename — the ``source`` a ``kind=local`` row
|
||||
#: records (``Path(path).name``), stable whatever temp root pytest picked.
|
||||
#:
|
||||
#: **Nothing here is allowed to contain the word "peak"** — not the source
|
||||
#: name, not a path, no marker. Several pins below ask whether the page
|
||||
#: says ANYTHING about memory (the running label must be clause-free, the
|
||||
#: anonymous shell must be silent), and those asks read the rendered text;
|
||||
#: a fixture path like ``peakmem/notes/peak-alpha.md`` rides the running
|
||||
#: label verbatim and would answer "yes" from the file name alone. So the
|
||||
#: fixture is ``memline-fixture/notes/mem-*.md`` and the clause stays the
|
||||
#: only thing on the page that can say the word.
|
||||
SOURCE_NAME = "memline-fixture"
|
||||
N_FILES = 2
|
||||
FIXTURE_FILES = {
|
||||
"notes/mem-alpha.md": (
|
||||
"# Memory line alpha\n"
|
||||
"\n"
|
||||
"Head marker RESE-MEMLINE-ALPHA-6d13 opens this note.\n"
|
||||
"\n"
|
||||
"## Alpha body\n"
|
||||
"\n"
|
||||
"A small note that exists so the phase-187 suite has a real run "
|
||||
"to watch: a real walk, real embeds, a real KB overview, and the "
|
||||
"run's resident-set clause on the line it settles.\n"
|
||||
),
|
||||
"notes/mem-beta.md": (
|
||||
"# Memory line beta\n"
|
||||
"\n"
|
||||
"Head marker RESE-MEMLINE-BETA-9a74 opens this note — the second "
|
||||
"file, the counts line's denominator.\n"
|
||||
),
|
||||
}
|
||||
|
||||
#: The clause sources.js appends (``fmtSyncPeakClause``): trailing text on
|
||||
#: a result line that already exists. REQUIRED here — this suite's whole
|
||||
#: point is that the number is there (the optional form, for the suites
|
||||
#: that only pin their own counts, lives in ``e2e.sync_helpers``).
|
||||
PEAK_CLAUSE = re.compile(r" · peak (\d+) MB$")
|
||||
|
||||
#: The clause SHAPE, for the "this surface says nothing about memory" pins
|
||||
#: (the running label, the anonymous shell). Matched case-insensitively on
|
||||
#: the word, because the clause is the only thing on these pages allowed to
|
||||
#: use it — which the fixture naming rule above is what keeps true.
|
||||
_ANY_PEAK = re.compile(r"peak", re.IGNORECASE)
|
||||
|
||||
#: The settled success line: the pre-phase ``fmtSyncResult`` counts line
|
||||
#: (a fresh KB of two files → "2 added", the zero "updated"/"unchanged"
|
||||
#: parts omitted) with the phase-187 clause trailing it.
|
||||
SUCCESS_LINE = re.compile(rf"^{N_FILES} added{PEAK_CLAUSE.pattern}$")
|
||||
|
||||
#: The neutral cancel line (phase 134/150 wording, byte-identical) with
|
||||
#: the clause trailing it.
|
||||
CANCELLED_LINE = re.compile(rf"^Sync cancelled{PEAK_CLAUSE.pattern}$")
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
|
||||
|
||||
#: The status payload's key set BEFORE phase 187 — the additive pin's
|
||||
#: left-hand side. The payload must stay exactly this set plus the memory
|
||||
#: pair, forever (the same list the integration suite pins).
|
||||
PRE_187_STATUS_KEYS = frozenset(
|
||||
{
|
||||
"state",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"detail",
|
||||
"error",
|
||||
"current_file",
|
||||
"files_done",
|
||||
"files_total",
|
||||
"current_chunk",
|
||||
"chunks_total",
|
||||
"images_done",
|
||||
"images_total",
|
||||
"phase",
|
||||
"current_summary",
|
||||
"summaries_done",
|
||||
"summaries_total",
|
||||
}
|
||||
)
|
||||
MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
|
||||
|
||||
#: The pre-phase integer count slots (their types are part of the
|
||||
#: unchanged-shape pin in test 2 — never a float, never null).
|
||||
_COUNT_KEYS = frozenset(
|
||||
{
|
||||
"files_done",
|
||||
"files_total",
|
||||
"current_chunk",
|
||||
"chunks_total",
|
||||
"images_done",
|
||||
"images_total",
|
||||
"summaries_done",
|
||||
"summaries_total",
|
||||
}
|
||||
)
|
||||
|
||||
#: The invariant the page can rely on: ``peak >= current``. The raw kernel
|
||||
#: counters do NOT give that for free (``VmRSS`` and ``ru_maxrss`` are two
|
||||
#: different sources, both floored to whole MB, and the kernel refreshes the
|
||||
#: high-water lazily — measured at ``rss_mb=164`` against
|
||||
#: ``rss_peak_mb=162``), so the ONE sampling point
|
||||
#: (:func:`app.api.sync._sample_rss`) lifts a lagging peak to the current
|
||||
#: reading. Slack ZERO: a peak BELOW the run's own current footprint is a
|
||||
#: false number, and the page must never show one.
|
||||
MB_SLACK = 0
|
||||
|
||||
#: Does THIS platform answer the two helpers? Linux (the deploy target:
|
||||
#: ``/proc`` + ``resource``) always does. The app under test is a
|
||||
#: subprocess of this process's own OS, so this answer is the app's answer.
|
||||
#: Where it is False the value pins step aside and the ABSENCE contract is
|
||||
#: pinned instead (null renders nothing — never "peak ? MB", never 0).
|
||||
MEMORY_KNOWABLE = rss_mb() is not None and rss_peak_mb() is not None
|
||||
|
||||
#: The house phone viewport at which the clause must still fit (the
|
||||
#: PLAN §7 / AGENTS rule 5 width: no horizontal overflow at 375 px).
|
||||
MOBILE_VIEWPORT: ViewportSize = {"width": 375, "height": 720}
|
||||
|
||||
#: Settle budgets: a two-file run under the proxy is ≈ 5 s and the UI's 2 s
|
||||
#: poll settles at most one tick after the terminal — 90 s is an order of
|
||||
#: magnitude of head-room (the house sync budget, no client timeout).
|
||||
SETTLE_TIMEOUT_MS = 90_000
|
||||
|
||||
#: The browser logs "Failed to load resource: … 403" for the anonymous
|
||||
#: probes (the whoami gate + the admin-only status fetch) — expected
|
||||
#: noise, not a defect.
|
||||
_CONSOLE_NOISE = re.compile(r"Failed to load resource", re.IGNORECASE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_source(root: Path) -> Path:
|
||||
"""(Re-)write the two fixture files under ``root / SOURCE_NAME``.
|
||||
|
||||
Idempotent on purpose: every test calls it, so the tree is at its
|
||||
ORIGINAL bytes whenever a test starts and a lone re-run of any single
|
||||
test still sees a fresh KB with ``2 added``.
|
||||
"""
|
||||
base = root / SOURCE_NAME
|
||||
for rel, body in FIXTURE_FILES.items():
|
||||
target = base / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(body, encoding="utf-8")
|
||||
assert not (base / ".git").exists() # a plain directory — no git anywhere
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def source_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The fixture tree the module app walks (the app server runs on the
|
||||
same host, so the path is visible to it)."""
|
||||
return _seed_source(tmp_path_factory.mktemp("bor_peak_memory"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module app + the timing proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||
(``tests/e2e/slow_llm.py``) — the ONLY thing that makes the RUNNING
|
||||
observation and the cancel window deterministic (module docstring)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
|
||||
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning",
|
||||
],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{SLOW_URL}/v1/models")
|
||||
yield SLOW_PORT
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int,
|
||||
slow_llm: int,
|
||||
source_dir: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The real app under test — per-module env (the conftest pattern):
|
||||
``BOR_LLM_BASE_URL`` is the SLOW proxy in front of the deterministic
|
||||
mock, ``BOR_GIT_SOURCES`` forced empty (each test registers its own
|
||||
local row; an operator's gitignored ``.env`` must never leak a real
|
||||
source into a run), and a dedicated ``BOR_SOURCES_DIR``.
|
||||
"""
|
||||
if USE_REAL_LLM:
|
||||
pytest.skip(
|
||||
"mock-only suite — the running window IS the slow proxy in "
|
||||
"front of the deterministic mock (E2E_REAL_LLM unsupported)"
|
||||
)
|
||||
assert mock_llm and source_dir
|
||||
scratch = tmp_path_factory.mktemp("bor_peak_memory_app")
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = f"{SLOW_URL}/v1"
|
||||
# Mock-calibrated gate (the conftest pattern): both floors must stay
|
||||
# <= the threshold or the app refuses to boot (app/config.py).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Instant retry waits + the code-default budget (the conftest leak
|
||||
# guard — this suite never needs a retry, and never waits 5 s for one).
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning",
|
||||
],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern, the phase-184 list:
|
||||
``sources_meta`` and ``folder_summaries`` reset with the KB)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
||||
"git_sources, sources_meta, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back
|
||||
|
||||
|
||||
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
|
||||
"""No run may leak across a truncate: wait for a non-running status with
|
||||
an own admin session (the endpoint is admin-only). Usually a no-op —
|
||||
every test settles before it ends."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
||||
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
if r.json()["state"] != "running":
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(app_url: str, source_dir: Path, db_ready: None) -> Iterator[None]:
|
||||
"""Per-test KB isolation: the shared E2E Postgres is truncated before
|
||||
AND after every test, the app holding no running sync at both
|
||||
boundaries. The fixture tree is re-seeded first (idempotent) and the
|
||||
local row is registered through the authenticated API, so every test
|
||||
owns a fresh two-file source and its own ``2 added``."""
|
||||
_wait_sync_settled(app_url)
|
||||
_truncate_all()
|
||||
_seed_source(source_dir.parent)
|
||||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(source_dir)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
yield
|
||||
_wait_sync_settled(app_url)
|
||||
_truncate_all()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(browser: Browser) -> Iterator[Page]:
|
||||
"""The conftest's page + the console-error collector: an uncaught JS
|
||||
exception (pageerror) or a console error that is not the browser's
|
||||
expected 403 "Failed to load resource" noise fails the owning test at
|
||||
teardown — a clause builder that threw inside ``setSyncProgress`` would
|
||||
otherwise settle a blank box and read as a passing test.
|
||||
|
||||
The conftest's ``page`` is NOT reused here because this suite's whole
|
||||
subject is the sync result line: a JS error inside the clause builder
|
||||
must fail the owning test, not leak (the ``test_sync_cancel.py``
|
||||
pattern)."""
|
||||
pg = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
errors: list[str] = []
|
||||
|
||||
def _on_console(msg: ConsoleMessage) -> None:
|
||||
if msg.type == "error" and not _CONSOLE_NOISE.search(msg.text):
|
||||
errors.append(msg.text)
|
||||
|
||||
pg.on("console", _on_console)
|
||||
pg.on("pageerror", lambda err: errors.append(str(err)))
|
||||
try:
|
||||
yield pg
|
||||
finally:
|
||||
pg.close()
|
||||
assert not errors, f"console/page errors: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _status(page: Page, base_url: str) -> dict[str, Any]:
|
||||
"""The status payload through the ADMIN browser context (the cookie
|
||||
rides ``page.request`` — the same surface the page's own poll uses)."""
|
||||
r = page.request.get(f"{base_url}/api/sync/status")
|
||||
assert r.status == 200, r.text
|
||||
body: dict[str, Any] = r.json()
|
||||
return body
|
||||
|
||||
|
||||
def _wait_sync_state(
|
||||
page: Page, base_url: str, states: set[str], timeout_s: float = 90.0
|
||||
) -> dict[str, Any]:
|
||||
"""Poll the status endpoint at ~100 ms until the run's state is in
|
||||
*states* (the deterministic layer: the UI's own poll ticks at 2 s)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
body = _status(page, base_url)
|
||||
if body["state"] in states:
|
||||
return body
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"sync did not reach {sorted(states)}: {body}")
|
||||
|
||||
|
||||
def _open_sources(page: Page, base_url: str) -> None:
|
||||
"""The real form login, landing on the Sources view (the sync button's
|
||||
home) with the module graph booted."""
|
||||
login(page, base_url) # lands on /sources.html
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(btn).to_be_enabled()
|
||||
|
||||
|
||||
def _click_sync(page: Page) -> None:
|
||||
"""Click **Sync sources** and pin the 202 moment: the button disables
|
||||
(the run is in flight), the box reveals with the bare running label —
|
||||
which carries NO peak clause (the clause is terminal-only)."""
|
||||
btn = page.locator("#sync-btn")
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
|
||||
|
||||
def _start_from_page(page: Page, base_url: str) -> None:
|
||||
"""Login on the Sources view, then the click (the two-step form of the
|
||||
same helper, so a test can change the viewport BETWEEN them)."""
|
||||
_open_sources(page, base_url)
|
||||
_click_sync(page)
|
||||
|
||||
|
||||
def _clause_number(text: str) -> int:
|
||||
"""The MB value the clause carries (loudly absent when there is none)."""
|
||||
m = PEAK_CLAUSE.search(text)
|
||||
assert m, f"no peak clause in {text!r}"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The success terminal: the clause on the line that already existed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_result_line_shows_the_peak(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""A real sync started from the Sources page ends with the number on
|
||||
the line the page already spoke — ``2 added · peak <N> MB`` — inside
|
||||
the sole aria-live region, as TEXT (no new control), and equal to the
|
||||
terminal payload's ``rss_peak_mb`` (the page and the log can never
|
||||
disagree, because they read the one forced terminal sample)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_start_from_page(page, app_url)
|
||||
|
||||
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
|
||||
assert terminal["state"] == "success", terminal
|
||||
assert terminal["error"] is None, terminal
|
||||
|
||||
# The UI settle (the page's own 2 s poll, one tick after the terminal).
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SETTLE_TIMEOUT_MS)
|
||||
box = page.locator("#sync-progress")
|
||||
expect(box).to_be_visible()
|
||||
text_el = page.locator("#sync-progress-text")
|
||||
line = text_el.inner_text()
|
||||
|
||||
if not MEMORY_KNOWABLE:
|
||||
# The mirror contract: no reading → NO clause at all (never
|
||||
# "peak ? MB", never "peak 0 MB"), and the counts line alone.
|
||||
assert line == f"{N_FILES} added", line
|
||||
assert not _ANY_PEAK.search(box.inner_text()), box.inner_text()
|
||||
return
|
||||
|
||||
# The clause is there AND the pre-phase wording is intact (the counts
|
||||
# line keeps its exact bytes; the clause trails it).
|
||||
assert SUCCESS_LINE.fullmatch(line), line
|
||||
assert PEAK_CLAUSE.search(line), line
|
||||
assert line.startswith(f"{N_FILES} added"), line
|
||||
|
||||
# The clause is TEXT on a line that already exists — the box still
|
||||
# holds its ONE child span (no new control, no new live region).
|
||||
expect(box.locator("> *")).to_have_count(1)
|
||||
|
||||
# The box IS the sole aria-live region and it announces the clause
|
||||
# (phase 150: one live region, one surface — and the clause rides it).
|
||||
expect(box).to_have_attribute("role", "status")
|
||||
expect(box).to_have_attribute("aria-live", "polite")
|
||||
expect(box).not_to_have_attribute("aria-hidden")
|
||||
assert PEAK_CLAUSE.search(box.inner_text()), box.inner_text()
|
||||
assert box.inner_text().strip() == line.strip(), (box.inner_text(), line)
|
||||
|
||||
# HONESTY: the megabytes on the page ARE the terminal payload's
|
||||
# ``rss_peak_mb`` — one forced reading, two surfaces.
|
||||
assert terminal["rss_peak_mb"] is not None, terminal
|
||||
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
|
||||
|
||||
# And the number survives the page: a reload re-attaches the terminal
|
||||
# (initSyncButton) and re-renders the SAME clause.
|
||||
page.reload()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
SUCCESS_LINE, timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
assert _clause_number(page.locator("#sync-progress-text").inner_text()) == (
|
||||
terminal["rss_peak_mb"]
|
||||
)
|
||||
|
||||
# No error surface on a success settle (unchanged contract).
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
assert page.locator(".sync-modal-backdrop").count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The payload: additive, typed, terminal-kept — and silent while running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_payload_reports_rss(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The additive pin end to end, through the browser's own authenticated
|
||||
request surface: while the run is in flight the payload already carries
|
||||
the pair (``int | null``) and the pre-phase keys are byte-for-byte
|
||||
unchanged, while the PAGE shows no clause at all; after the terminal the
|
||||
peak is a positive integer that stays with the run, and the pair keeps
|
||||
the invariant the page relies on (``peak >= current`` — the sampling
|
||||
point lifts the lazily-refreshed high-water, ``MB_SLACK`` is 0).
|
||||
"""
|
||||
page.set_default_timeout(30_000)
|
||||
_start_from_page(page, app_url)
|
||||
|
||||
# --- during `running` ------------------------------------------------
|
||||
running = _wait_sync_state(page, app_url, {"running"})
|
||||
assert running["state"] == "running", running
|
||||
# The additive contract on the wire: exactly the pre-phase keys plus
|
||||
# the memory pair — nothing renamed, nothing dropped.
|
||||
assert set(running) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(running)
|
||||
for key, value in running.items():
|
||||
if key in MEMORY_KEYS:
|
||||
# The documented type: int | null (null is "no reading").
|
||||
assert value is None or isinstance(value, int), (key, value)
|
||||
elif key == "detail":
|
||||
assert isinstance(value, dict), (key, value)
|
||||
elif key in _COUNT_KEYS:
|
||||
assert isinstance(value, int), (key, value)
|
||||
else:
|
||||
assert value is None or isinstance(value, (str, int, bool)), (key, value)
|
||||
# The types are ints on a platform that knows them (the run start
|
||||
# FORCES the first sample, so a running tick is never null here).
|
||||
if MEMORY_KNOWABLE:
|
||||
assert running["rss_mb"] is not None, running
|
||||
assert running["rss_peak_mb"] is not None, running
|
||||
assert running["rss_peak_mb"] >= running["rss_mb"] - MB_SLACK, running
|
||||
|
||||
# The RUNNING state shows NO clause: the box carries the live label and
|
||||
# says nothing about memory (the clause is terminal-only in the UI). The
|
||||
# word is the tell (the fixture names never carry it), so a clause that
|
||||
# leaked into a running label fails even when its number is null-ish.
|
||||
live_text = page.locator("#sync-progress-text").inner_text()
|
||||
assert live_text.startswith("Syncing…"), live_text
|
||||
assert not _ANY_PEAK.search(live_text), live_text
|
||||
assert not _ANY_PEAK.search(page.locator("#sync-progress").inner_text())
|
||||
|
||||
# --- after the terminal ---------------------------------------------
|
||||
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
|
||||
assert terminal["state"] == "success", terminal
|
||||
assert set(terminal) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(terminal)
|
||||
current, peak = terminal["rss_mb"], terminal["rss_peak_mb"]
|
||||
if MEMORY_KNOWABLE:
|
||||
assert peak is not None and isinstance(peak, int), terminal
|
||||
assert peak > 0, terminal # a real reading, never a placeholder 0
|
||||
assert current is not None and current > 0, terminal
|
||||
assert peak >= current - MB_SLACK, (current, peak)
|
||||
else:
|
||||
assert peak is None and current is None, terminal
|
||||
|
||||
# The pair SURVIVES the run (no reset between the terminal and a later
|
||||
# poll — the page can be reloaded an hour later and still answers).
|
||||
again = _status(page, app_url)
|
||||
assert (again["rss_mb"], again["rss_peak_mb"]) == (current, peak), again
|
||||
# …and the pre-existing values rode the terminal unchanged too.
|
||||
assert again["state"] == "success"
|
||||
assert again["error"] is None
|
||||
assert isinstance(again["detail"], dict) and again["detail"]["added"] == N_FILES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Anonymous: no memory number on any anonymous surface (A4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_memory(
|
||||
page: Page, browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The exposure guard is the status route's own admin gate: a logged-out
|
||||
visitor gets the pre-phase 403 with the pre-phase body, before or after
|
||||
a run — and the Sources shell shows the sign-in gate with no sync button
|
||||
and no memory clause anywhere in the DOM.
|
||||
|
||||
The probe runs in a SEPARATE browser context (``browser.new_context()``)
|
||||
— this test's own page is logged in by the run above, and
|
||||
``page.context.new_page()`` would SHARE that session's cookies, which
|
||||
would make the "anonymous" probe an admin one."""
|
||||
# A run FIRST, so the guard is proven with real numbers in existence.
|
||||
_start_from_page(page, app_url)
|
||||
terminal = _wait_sync_state(page, app_url, {"success"})
|
||||
if MEMORY_KNOWABLE:
|
||||
assert terminal["rss_peak_mb"] is not None, terminal
|
||||
|
||||
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
anon = ctx.new_page()
|
||||
r = anon.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 403, r.text
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
# The anonymous shell: the gate is the anonymity proof, the sync
|
||||
# controls are never revealed, and nothing about memory renders.
|
||||
anon.goto(f"{app_url}/sources.html")
|
||||
expect(anon.locator("#sources-gate")).to_be_visible(timeout=30_000)
|
||||
expect(anon.locator("#sync-btn")).to_be_hidden()
|
||||
expect(anon.locator("#sync-progress")).to_be_hidden()
|
||||
assert not _ANY_PEAK.search(anon.locator("body").inner_text())
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The cancelled terminal keeps the peak (the test_sync_cancel shape)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cancelled_terminal_keeps_the_peak(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""Cancel mid-run: the run lands in the NEUTRAL ``cancelled`` terminal
|
||||
(no banner, no modal, retry-ready) and the box still carries the
|
||||
clause — "Sync cancelled · peak <N> MB" — equal to the cancelled
|
||||
payload's ``rss_peak_mb``. A cancelled run's memory is still data: the
|
||||
phase-64 keep-final-values convention covers it like the counts."""
|
||||
page.set_default_timeout(30_000)
|
||||
_start_from_page(page, app_url)
|
||||
|
||||
_wait_sync_state(page, app_url, {"running"})
|
||||
cancel = page.locator("#sync-cancel-btn")
|
||||
expect(cancel).to_be_visible()
|
||||
expect(cancel).to_be_enabled()
|
||||
cancel.click()
|
||||
expect(cancel).to_be_disabled() # never stale, one request at a time
|
||||
|
||||
body = _wait_sync_state(page, app_url, {"cancelled"}, timeout_s=30.0)
|
||||
assert body["state"] == "cancelled"
|
||||
assert body["error"] is None # a cancel is intent, not failure
|
||||
assert body["detail"] == {}
|
||||
|
||||
# The neutral settle, clause and all (the 2 s poll, one tick later).
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
|
||||
expect(page.locator("#sync-btn")).to_be_enabled()
|
||||
expect(cancel).to_be_hidden()
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
assert page.locator(".sync-modal-backdrop").count() == 0
|
||||
|
||||
line = page.locator("#sync-progress-text").inner_text()
|
||||
if MEMORY_KNOWABLE:
|
||||
assert body["rss_peak_mb"] is not None, body
|
||||
assert body["rss_peak_mb"] > 0, body
|
||||
assert CANCELLED_LINE.fullmatch(line), line
|
||||
assert _clause_number(line) == body["rss_peak_mb"], (line, body)
|
||||
else:
|
||||
assert line == "Sync cancelled", line
|
||||
# The pair survives the cancel exactly like the counts do.
|
||||
again = _status(page, app_url)
|
||||
assert (again["rss_mb"], again["rss_peak_mb"]) == (
|
||||
body["rss_mb"],
|
||||
body["rss_peak_mb"],
|
||||
), again
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. PLAN §7 at 375 px: the clause fits, the page never scrolls sideways
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_peak_clause_fits_at_375(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The clause at phone width: the result line wraps INSIDE the bordered
|
||||
box (``overflow-wrap: anywhere``) — nothing clipped, no horizontal page
|
||||
overflow, the box inside the viewport (AGENTS rule 5 / PLAN §7). The
|
||||
clause is a few characters of trailing text, so a layout shift or a
|
||||
clipped tail at 375 px is exactly what this pins out."""
|
||||
_open_sources(page, app_url)
|
||||
page.set_viewport_size(MOBILE_VIEWPORT) # the phone width, then the click
|
||||
page.wait_for_timeout(150) # the reflow settles before the run paints
|
||||
_click_sync(page)
|
||||
terminal = _wait_sync_state(page, app_url, {"success"})
|
||||
|
||||
text_el = page.locator("#sync-progress-text")
|
||||
expect(text_el).to_have_text(
|
||||
SUCCESS_LINE if MEMORY_KNOWABLE else f"{N_FILES} added",
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
line = text_el.inner_text()
|
||||
if MEMORY_KNOWABLE:
|
||||
assert PEAK_CLAUSE.search(line), line
|
||||
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
|
||||
|
||||
# The box + its text do not clip, the box stays in the viewport, and
|
||||
# the PAGE itself never scrolls sideways at 375 px.
|
||||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||||
geometry = page.evaluate(
|
||||
"""() => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const b = box.getBoundingClientRect();
|
||||
return {
|
||||
boxScrollWidth: box.scrollWidth,
|
||||
boxClientWidth: box.clientWidth,
|
||||
textScrollWidth: textEl.scrollWidth,
|
||||
textClientWidth: textEl.clientWidth,
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
height: b.height,
|
||||
docScroll: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
text: textEl.textContent,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
assert geometry["height"] > 0, geometry
|
||||
assert geometry["boxScrollWidth"] <= geometry["boxClientWidth"], geometry
|
||||
assert geometry["textScrollWidth"] <= geometry["textClientWidth"], geometry
|
||||
assert geometry["left"] >= 0, geometry
|
||||
assert geometry["right"] <= MOBILE_VIEWPORT["width"] + 1, geometry
|
||||
assert geometry["docScroll"] <= geometry["innerWidth"] + 1, geometry
|
||||
assert PEAK_CLAUSE.search(geometry["text"]) or not MEMORY_KNOWABLE, geometry
|
||||
@@ -183,6 +183,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -267,8 +268,12 @@ WALL_BUDGET_S = 600.0
|
||||
|
||||
#: The settle's counts line (sources.js's fmtSyncResult — the first
|
||||
#: part is always "N added"; the rest are conditional, " · "-joined —
|
||||
#: the shape the pre-phase announcer spoke, byte-identical).
|
||||
RESULT_LINE = re.compile(r"^\d+ added( · \d+ (updated|unchanged|pruned))*$")
|
||||
#: the shape the pre-phase announcer spoke, byte-identical) plus the
|
||||
#: optional phase-187 peak clause — the counts are still anchored, so
|
||||
#: a mutated counts line still fails this shape.
|
||||
RESULT_LINE = re.compile(
|
||||
rf"^\d+ added( · \d+ (updated|unchanged|pruned))*{PEAK_CLAUSE}$"
|
||||
)
|
||||
|
||||
#: A broken git source — the failure trigger (test 8): a ``file://``
|
||||
#: URL with no repository behind it (the URL shape passes add-time
|
||||
@@ -1146,7 +1151,7 @@ def test_settled_state_restores_the_pill(
|
||||
# counts line (the fmtSyncResult shape the pre-phase announcer
|
||||
# spoke, byte-identical).
|
||||
expect(page.locator("#sync-progress")).to_be_visible()
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
# The label parts are restored — the settle label carries no count
|
||||
# or chunk part.
|
||||
count = page.locator(".sync-label-count")
|
||||
@@ -1243,7 +1248,7 @@ def test_settled_cancel_shows_the_result_line(
|
||||
# line (byte-identical to the pre-phase announcer's string)…
|
||||
expect(page.locator("#sync-progress")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
"Sync cancelled", timeout=30_000
|
||||
counts_line("Sync cancelled"), timeout=30_000
|
||||
)
|
||||
# …the button settles retry-ready…
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
|
||||
|
||||
@@ -130,6 +130,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line, strip_peak_clause
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -497,12 +498,18 @@ def _sync_from_sources_page(page: Page, base_url: str) -> dict[str, Any]:
|
||||
def _expect_counts_line(page: Page, expected: str) -> None:
|
||||
"""The counts line — the owner-visible sync report on the single sync text
|
||||
surface (phase 150; sources.js fmtSyncResult)."""
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
|
||||
|
||||
|
||||
def _counts_line(page: Page) -> str:
|
||||
"""The counts line as text — the string test 4 compares across the apps."""
|
||||
return str(page.locator("#sync-progress-text").inner_text())
|
||||
"""The counts line as text — the string test 4 compares across the apps.
|
||||
|
||||
Phase 187 (task 03): the peak clause is stripped first. The byte-
|
||||
identity this test pins is the KB's (the same ten files report the same
|
||||
counts under both cadences); the megabytes belong to the process that
|
||||
ran them and differ between the two module apps by design.
|
||||
"""
|
||||
return strip_peak_clause(str(page.locator("#sync-progress-text").inner_text()))
|
||||
|
||||
|
||||
def _open_level(page: Page, *names: str) -> None:
|
||||
|
||||
@@ -98,6 +98,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -497,7 +498,7 @@ def test_fresh_sync_settles_same_day_shape(
|
||||
# the status is the source of truth for the settle; the UI's 2 s
|
||||
# poll renders it at most one tick later).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
f"{len(SYNC_FILES)} added", timeout=SYNC_TIMEOUT_MS
|
||||
counts_line(f"{len(SYNC_FILES)} added"), timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
|
||||
@@ -124,6 +124,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -807,7 +808,7 @@ def test_sync_button_names_the_summary_phase(
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
# The counts line rides the box (phase 150 — the sole sync surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -132,6 +132,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -647,11 +648,11 @@ def test_upload_toast_then_navigate_away(
|
||||
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||
# and the button title carries the same full string (A4 — one
|
||||
# source, two surfaces).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(
|
||||
re.compile(
|
||||
rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"
|
||||
r"(?: · chunk \d+/\d+)?$"
|
||||
),
|
||||
)),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# The box is the VISIBLE running surface (the count's old
|
||||
@@ -686,7 +687,7 @@ def test_upload_toast_then_navigate_away(
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
# The counts line rides the box (phase 150 — the sole sync surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
# Phase 97: the catalog is the drill-down tree — drill source →
|
||||
# folder `docs` before the per-level row asserts (the flat total
|
||||
# no longer exists in one tbody).
|
||||
@@ -844,11 +845,11 @@ def test_sync_live_file_label(
|
||||
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||
# and the button title carries the same full string (A4 — one
|
||||
# source, two surfaces).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(
|
||||
re.compile(
|
||||
rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"
|
||||
r"(?: · chunk \d+/\d+)?$"
|
||||
),
|
||||
)),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# The box is the VISIBLE running surface (the count's old
|
||||
@@ -885,7 +886,7 @@ def test_sync_live_file_label(
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
# The counts line rides the box (phase 150 — the sole sync surface).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
|
||||
# Phase 97: the catalog is the drill-down tree — drill source →
|
||||
# folder `notes` before the per-level row asserts (the flat total
|
||||
# no longer exists in one tbody).
|
||||
|
||||
@@ -111,6 +111,7 @@ from e2e.conftest import (
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
from e2e.sync_helpers import counts_line
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -574,7 +575,9 @@ def test_ignore_list_then_sync_scans(
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text("2 added", timeout=SYNC_TIMEOUT_MS)
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
counts_line("2 added"), timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-btn")).to_be_enabled()
|
||||
expect(page.locator("#sync-btn")).not_to_have_attribute("aria-busy")
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ Runs the real import pipeline (walk → chunk → embed → upsert) into the
|
||||
local compose Postgres, then checks the DB state *and* the API shape a
|
||||
browser would consume. Embeddings come from a deterministic in-process
|
||||
fake, so no network is needed.
|
||||
|
||||
Phase 187 (task 02) pins the PLAN §9 cron-safe summary line's ONE new
|
||||
trailing field (``rss_mb=``) on both the initial import and the
|
||||
idempotent re-run, with every earlier field byte-identical.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from app.models import Chunk, Document
|
||||
@@ -36,9 +43,22 @@ EXPECTED_DOCS = {
|
||||
}
|
||||
|
||||
|
||||
def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
#: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
|
||||
#: allows for an MB field — ``?`` when the platform cannot know, else a
|
||||
#: positive integer (never ``None``, never a float or a negative).
|
||||
MB = r"(?:\?|[1-9][0-9]*)"
|
||||
|
||||
|
||||
def test_import_fixtures_end_to_end(
|
||||
admin_client, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
# Phase 187 (task 02): the PLAN §9 cron-safe summary line gains a
|
||||
# trailing ``rss_mb=`` — captured for BOTH runs below (the initial
|
||||
# import and the idempotent re-run), the level set once so every
|
||||
# ``import_sources`` call of this test logs at INFO.
|
||||
caplog.set_level(logging.INFO, logger="app.importer")
|
||||
llm = FakeEmbedder()
|
||||
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
@@ -111,5 +131,28 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
assert s2.unchanged == 13 and s2.added == 0
|
||||
assert len(llm.calls) == calls_before # unchanged → no embedding requests
|
||||
|
||||
# Phase 187 (task 02): every run's summary line ends with the ONE
|
||||
# appended memory field, and the pre-phase fields are byte-identical
|
||||
# (LOCKED A2 — append-only, so every existing grep keeps working).
|
||||
summary_lines = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.getMessage().startswith("import: summary files=")
|
||||
]
|
||||
assert len(summary_lines) == 2, summary_lines
|
||||
for line in summary_lines:
|
||||
assert re.search(rf" rss_mb={MB}$", line), line
|
||||
assert summary_lines[0].startswith(
|
||||
"import: summary files=13 added=13 updated=0 unchanged=0 pruned=0 errors=0 "
|
||||
)
|
||||
assert re.search(
|
||||
rf"formats=md:5,container:1,j2:1,json:1,network:1,py:1,txt:1,volume:1,yaml:1"
|
||||
rf" rss_mb={MB}$",
|
||||
summary_lines[0],
|
||||
), summary_lines[0]
|
||||
assert summary_lines[1].startswith(
|
||||
"import: summary files=13 added=0 updated=0 unchanged=13 pruned=0 errors=0 "
|
||||
)
|
||||
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
@@ -86,12 +86,20 @@ empty, the final file counts kept, the chunk position cleared) and a
|
||||
second run after the cancel completes to ``success`` (the restart is
|
||||
clean — the stale cancel event did not bleed in, and only the
|
||||
completed run bumps the sources version).
|
||||
|
||||
Phase 187 (task 02): every sync LIFECYCLE line carries the process
|
||||
memory — ``sync: started … rss_mb=N``, and ``… rss_mb=N rss_peak_mb=M``
|
||||
on all three terminals (success, cancelled, failed) plus the importer's
|
||||
``import: summary …``. The fields are APPENDED (LOCKED A2), so every
|
||||
pre-phase prefix below is byte-identical and the pins here assert BOTH
|
||||
halves of that contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
import threading
|
||||
@@ -109,6 +117,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import sync as sync_api
|
||||
from app.config import Settings
|
||||
from app.core.memory import fmt_mb, rss_mb, rss_peak_mb
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Document, GitSource
|
||||
@@ -130,14 +139,21 @@ def _fresh_sync_state() -> Iterator[None]:
|
||||
|
||||
Phase 134 (task 01): the module ``_cancel_event`` joins the reset —
|
||||
a stale (set) event from a previous test's cancelled run would
|
||||
otherwise bleed into the next run's boundary checks."""
|
||||
otherwise bleed into the next run's boundary checks.
|
||||
|
||||
Phase 187 (task 03): so does the memory-sampling guard
|
||||
(``_rss_sampled_at``) — module-global state beside ``_status``.
|
||||
Leaving it set would make one test's sample answer for the next
|
||||
one's "did the run read the number?" assertion."""
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
sync_api._rss_sampled_at = float("-inf")
|
||||
yield
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
sync_api._rss_sampled_at = float("-inf")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -307,6 +323,21 @@ def _wait_phase(client: TestClient, want: str, timeout: float = 5.0) -> dict:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
#: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
|
||||
#: allows for an MB field — ``?`` when the platform cannot know (no
|
||||
#: ``/proc``, no ``resource``), else a positive integer (never ``None``,
|
||||
#: never a float or a negative).
|
||||
MB = r"(?:\?|[1-9][0-9]*)"
|
||||
|
||||
|
||||
def _log_lines(caplog: pytest.LogCaptureFixture, prefix: str) -> list[str]:
|
||||
"""Captured messages starting with ``prefix`` — the lifecycle lines
|
||||
are pinned by their PRE-phase prefix, so the append-only contract
|
||||
(LOCKED A2) is visible in the assertion itself.
|
||||
"""
|
||||
return [r.getMessage() for r in caplog.records if r.getMessage().startswith(prefix)]
|
||||
|
||||
|
||||
class FakeImportSources:
|
||||
"""Records every ``import_sources`` call; returns a canned summary."""
|
||||
|
||||
@@ -546,6 +577,10 @@ def test_admin_sync_success_reports_full_detail(
|
||||
"current_summary": None,
|
||||
"summaries_done": 0,
|
||||
"summaries_total": 0,
|
||||
# Phase 187 (task 03): the memory keys — null/null on a run that
|
||||
# never happened (never 0, and the page renders no peak clause).
|
||||
"rss_mb": None,
|
||||
"rss_peak_mb": None,
|
||||
}
|
||||
|
||||
r = sync_client.post("/api/sync")
|
||||
@@ -1567,7 +1602,11 @@ def test_summaries_phase_reports_the_affected_count_as_total(
|
||||
|
||||
|
||||
def test_git_failure_marks_failed_and_skips_import(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'bad.git'}"
|
||||
_seed(db, repo_url)
|
||||
@@ -1592,8 +1631,8 @@ def test_git_failure_marks_failed_and_skips_import(
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
_login(sync_client)
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "bad.git" in body["error"] # the failing repo is named
|
||||
assert "fatal: repository not found" in body["error"]
|
||||
@@ -1613,6 +1652,21 @@ def test_git_failure_marks_failed_and_skips_import(
|
||||
# Phase 53: a FAILED sync never bumps — the version is untouched.
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
# Phase 187 (task 02): the failed funnel keeps its traceback AND
|
||||
# gains ONE sanitized one-line summary (LOCKED A2 — appended, so the
|
||||
# pre-phase ``sync: failed`` label is untouched). Asserted before the
|
||||
# restart below, which fails a SECOND time.
|
||||
tracebacks = [
|
||||
r for r in caplog.records if r.getMessage() == "sync: failed" and r.exc_info
|
||||
]
|
||||
assert len(tracebacks) == 1, "logger.exception must still write the traceback"
|
||||
failed = _log_lines(caplog, "sync: failed state=failed")
|
||||
assert len(failed) == 1, failed
|
||||
assert failed[0].startswith("sync: failed state=failed files_done=0 files_total=0")
|
||||
assert re.search(
|
||||
rf" rss_mb={MB} rss_peak_mb={MB} error=.*bad\.git", failed[0]
|
||||
), failed[0]
|
||||
|
||||
# A failed run leaves the system restartable: a new POST is accepted.
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "failed")
|
||||
@@ -1855,7 +1909,11 @@ def test_missing_local_dir_fails_loudly_and_imports_nothing(
|
||||
|
||||
|
||||
def test_import_error_is_reported_with_credentials_masked(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
_seed(db, repo_url)
|
||||
@@ -1892,8 +1950,8 @@ def test_import_error_is_reported_with_credentials_masked(
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
|
||||
_login(sync_client)
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "*****@aipi.reeseapps.com" in body["error"] # credentials masked
|
||||
assert "user:secret" not in body["error"]
|
||||
@@ -1905,6 +1963,16 @@ def test_import_error_is_reported_with_credentials_masked(
|
||||
assert body["current_summary"] is None
|
||||
assert body["summaries_done"] == 0 and body["summaries_total"] == 0
|
||||
|
||||
# Phase 187 (task 02): the one-line failed summary logs the SAME
|
||||
# masked string the status field carries — the raw exception text
|
||||
# never reaches the log through this line (the ``logger.exception``
|
||||
# traceback is the pre-phase surface, unchanged).
|
||||
failed = _log_lines(caplog, "sync: failed state=failed")
|
||||
assert len(failed) == 1, failed
|
||||
assert "*****@aipi.reeseapps.com" in failed[0]
|
||||
assert "user:secret" not in failed[0]
|
||||
assert re.search(rf" rss_mb={MB} rss_peak_mb={MB} error=", failed[0]), failed[0]
|
||||
|
||||
|
||||
# --- phase 41: model probe (fail fast before any clone) --------------------
|
||||
|
||||
@@ -2350,12 +2418,29 @@ def test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean(
|
||||
assert body["files_done"] == 1 and body["files_total"] == 3
|
||||
# ... and the chunk position clears (the phase-129 convention).
|
||||
assert body["current_chunk"] == 0 and body["chunks_total"] == 0
|
||||
# PLAN §9: the ample-logging line for the converted cancel.
|
||||
assert any(
|
||||
r.getMessage()
|
||||
== "sync: cancelled by user state=cancelled files_done=1 files_total=3"
|
||||
for r in caplog.records
|
||||
# Phase 187 (task 03): the CANCELLED terminal keeps the memory pair
|
||||
# too — a cancelled run is exactly the run an operator wants the
|
||||
# number for (the walk stopped, the box is still hot), and the
|
||||
# phase-64 keep-final-values convention covers it like the counts.
|
||||
if _memory_is_knowable():
|
||||
assert body["rss_mb"] is not None and body["rss_peak_mb"] is not None, body
|
||||
assert body["rss_peak_mb"] >= body["rss_mb"] - _MB_SLACK, body
|
||||
else:
|
||||
assert body["rss_mb"] is None and body["rss_peak_mb"] is None, body
|
||||
# PLAN §9: the ample-logging line for the converted cancel. Phase
|
||||
# 187 (task 02) APPENDS the two memory fields (LOCKED A2) — the
|
||||
# pre-phase prefix is byte-identical, the tail is `?` or a positive
|
||||
# integer, and NOTHING else was inserted or reworded.
|
||||
cancelled = _log_lines(caplog, "sync: cancelled by user ")
|
||||
assert len(cancelled) == 1, cancelled
|
||||
assert cancelled[0].startswith(
|
||||
"sync: cancelled by user state=cancelled files_done=1 files_total=3"
|
||||
)
|
||||
assert re.fullmatch(
|
||||
rf"sync: cancelled by user state=cancelled files_done=1 files_total=3"
|
||||
rf" rss_mb={MB} rss_peak_mb={MB}",
|
||||
cancelled[0],
|
||||
), cancelled[0]
|
||||
# The cancelled run never reached the version bump.
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
@@ -2518,3 +2603,281 @@ def test_git_row_branch_mismatch_button_path_fails_loud(
|
||||
assert proc.returncode == 0 and proc.stdout.strip() == "main"
|
||||
assert (dest / "a.md").is_file()
|
||||
assert not (dest / "b.md").exists()
|
||||
|
||||
|
||||
# --- phase 187 (task 02): the lifecycle lines carry the memory numbers ----
|
||||
|
||||
|
||||
def test_success_lifecycle_lines_carry_memory_fields(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The owner incident (2026-10-15) turned "how much RAM did that sync
|
||||
use?" into archaeology across three boots' ``systemd`` teardown lines
|
||||
— with one boot missing entirely. From now on ONE grep answers it:
|
||||
``sync: started … rss_mb=N`` and ``sync: done detail=… rss_mb=N
|
||||
rss_peak_mb=M``. Both fields are APPENDED after the existing ones
|
||||
(LOCKED A2), so the pre-phase prefix stays byte-identical; the value
|
||||
is a positive integer MB or ``?`` where the platform cannot know
|
||||
(:mod:`app.core.memory` never raises).
|
||||
"""
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
_seed(db, repo_url)
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_stub_probe(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api, "import_sources", FakeImportSources(ImportSummary(files=1, added=1))
|
||||
)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
|
||||
_login(sync_client)
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
assert body["state"] == "success"
|
||||
|
||||
started = _log_lines(caplog, "sync: started ")
|
||||
assert len(started) == 1, started
|
||||
assert started[0].startswith("sync: started repos=1 origin=db git=1 local=0")
|
||||
assert re.fullmatch(
|
||||
rf"sync: started repos=1 origin=db git=1 local=0 rss_mb={MB}", started[0]
|
||||
), started[0]
|
||||
|
||||
done = _log_lines(caplog, "sync: done ")
|
||||
assert len(done) == 1, done
|
||||
# The pre-existing prefix, the detail dict still first, the two new
|
||||
# fields trailing — nothing renamed, reordered or re-typed.
|
||||
assert done[0].startswith("sync: done detail={'files': 1,")
|
||||
assert re.search(rf" rss_mb={MB} rss_peak_mb={MB}$", done[0]), done[0]
|
||||
|
||||
|
||||
# --- phase 187 (task 03): the status carries the memory pair --------------
|
||||
#
|
||||
# The same numbers the lifecycle lines carry, on the surface the Sources
|
||||
# page polls: two ADDITIVE keys (rss_mb / rss_peak_mb, int | null),
|
||||
# null until a run samples them and the FINAL pair in every terminal
|
||||
# (the phase-64 keep-final-values convention). The exposure guard is the
|
||||
# route's own admin gate — the Sources view is admin-only (PLAN B5), so
|
||||
# no memory number ever leaves the app on an anonymous surface.
|
||||
|
||||
#: The status payload's key set BEFORE phase 187 — the additive pin's
|
||||
#: left-hand side (the payload is exactly this set plus the memory pair,
|
||||
#: forever).
|
||||
_PRE_187_STATUS_KEYS = frozenset(
|
||||
{
|
||||
"state",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"detail",
|
||||
"error",
|
||||
"current_file",
|
||||
"files_done",
|
||||
"files_total",
|
||||
"current_chunk",
|
||||
"chunks_total",
|
||||
"images_done",
|
||||
"images_total",
|
||||
"phase",
|
||||
"current_summary",
|
||||
"summaries_done",
|
||||
"summaries_total",
|
||||
}
|
||||
)
|
||||
|
||||
#: Phase 187 (task 03): the two additive memory keys.
|
||||
_MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
|
||||
|
||||
#: The pair is read from two DIFFERENT kernel counters and the high-water
|
||||
#: refreshes LAZILY, so a raw ``rss_peak_mb()`` really can sit under a raw
|
||||
#: ``rss_mb()`` (measured: ``rss_mb=164`` against ``rss_peak_mb=162``) —
|
||||
#: the nuance ``app/core/memory.py`` documents. Task 04 moved the fix to
|
||||
#: the ONE place the pair is published (:func:`app.api.sync._sample_rss`,
|
||||
#: which lifts a lagging peak to the current reading), so the slack here is
|
||||
#: ZERO and the assertion is the invariant itself: a reported peak below
|
||||
#: the run's own current footprint is a false number, not a smaller one.
|
||||
_MB_SLACK = 0
|
||||
|
||||
|
||||
def _memory_is_knowable() -> bool:
|
||||
"""Does THIS platform answer the two helpers? Linux (the deploy
|
||||
target: ``/proc`` + ``resource``) always does; a runner without
|
||||
either gets null/null and the value pins step aside — the SHAPE pins
|
||||
never do, because ``null`` is the honest answer there too."""
|
||||
return rss_mb() is not None and rss_peak_mb() is not None
|
||||
|
||||
|
||||
def _stub_a_one_file_run(
|
||||
monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""The shortest honest run: one git row, a stub clone that writes one
|
||||
file, a stubbed import (the runner's own numbers are what's under
|
||||
test, not the importer's)."""
|
||||
_seed(db, f"file://{tmp_path / 'repo.git'}")
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_stub_probe(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api, "import_sources", FakeImportSources(ImportSummary(files=1, added=1))
|
||||
)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
|
||||
|
||||
def test_memory_status_keys_are_the_only_addition_and_null_before_a_run(
|
||||
sync_client: TestClient,
|
||||
) -> None:
|
||||
"""The additive pin on the wire: the payload's key set is EXACTLY the
|
||||
pre-phase-187 set plus ``rss_mb`` / ``rss_peak_mb``, and a run that
|
||||
never happened answers ``null`` / ``null`` — not 0, not a plausible
|
||||
number. That null is also what makes the Sources page render the
|
||||
counts line with no peak clause after an app restart."""
|
||||
_login(sync_client)
|
||||
body = sync_client.get("/api/sync/status").json()
|
||||
assert set(body) == _PRE_187_STATUS_KEYS | _MEMORY_KEYS, set(body)
|
||||
assert body["rss_mb"] is None
|
||||
assert body["rss_peak_mb"] is None
|
||||
# The pre-existing shapes, unchanged: the state word, the two ISO
|
||||
# slots, the counts, the phase machine.
|
||||
assert body["state"] == "idle"
|
||||
assert body["started_at"] is None and body["finished_at"] is None
|
||||
assert body["detail"] == {} and body["error"] is None
|
||||
assert body["files_done"] == 0 and body["files_total"] == 0
|
||||
assert body["current_chunk"] == 0 and body["chunks_total"] == 0
|
||||
assert body["images_done"] == 0 and body["images_total"] == 0
|
||||
assert body["phase"] is None and body["current_summary"] is None
|
||||
assert body["summaries_done"] == 0 and body["summaries_total"] == 0
|
||||
|
||||
|
||||
def test_success_terminal_status_keeps_the_final_memory_pair(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""After a run the payload answers the incident's question directly:
|
||||
both keys are integers here (Linux), the peak is the run's
|
||||
high-water next to the final counts (within the documented MB
|
||||
slack), and the pair SURVIVES the run — a later poll, i.e. the page
|
||||
reloaded after the sync, still shows it. The ``sync: done`` line
|
||||
logs the SAME pair: one reading, two surfaces."""
|
||||
_stub_a_one_file_run(monkeypatch, db, tmp_path)
|
||||
|
||||
_login(sync_client)
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
assert body["state"] == "success"
|
||||
current, peak = body["rss_mb"], body["rss_peak_mb"]
|
||||
assert current is None or isinstance(current, int)
|
||||
assert peak is None or isinstance(peak, int)
|
||||
if _memory_is_knowable():
|
||||
assert current is not None and peak is not None, body
|
||||
assert current > 0 and peak > 0, body
|
||||
assert peak >= current - _MB_SLACK, (
|
||||
"the published pair broke its peak >= current invariant — the"
|
||||
" sampling point must lift a lazily-refreshed high-water to the"
|
||||
f" current reading (slack {_MB_SLACK} MB):"
|
||||
f" current={current} peak={peak}"
|
||||
)
|
||||
|
||||
# The terminal keeps the pair (no re-sample, no reset between the
|
||||
# run and the next poll — the page can be reloaded at leisure).
|
||||
again = sync_client.get("/api/sync/status").json()
|
||||
assert (again["rss_mb"], again["rss_peak_mb"]) == (current, peak), again
|
||||
|
||||
# Log / payload agreement: the line the operator greps and the key
|
||||
# the page renders come off the same reading.
|
||||
done = _log_lines(caplog, "sync: done ")
|
||||
assert len(done) == 1, done
|
||||
assert done[0].endswith(
|
||||
f"rss_mb={fmt_mb(current)} rss_peak_mb={fmt_mb(peak)}"
|
||||
), done[0]
|
||||
# The pre-existing payload is untouched by all of this.
|
||||
assert set(again) == _PRE_187_STATUS_KEYS | _MEMORY_KEYS
|
||||
assert again["files_done"] == 0 or isinstance(again["files_done"], int)
|
||||
|
||||
|
||||
def test_failed_terminal_keeps_the_memory_pair(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A run that dies is exactly the run whose memory matters, so the
|
||||
FAILED funnel samples too: the payload keeps the pair beside the
|
||||
sanitized error (the task-02 line logs the same pair)."""
|
||||
repo_url = f"file://{tmp_path / 'missing.git'}"
|
||||
_seed(db, repo_url)
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_stub_probe(monkeypatch)
|
||||
|
||||
def broken_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
raise GitSyncError(f"git clone failed for {url}")
|
||||
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", broken_clone)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"import_sources",
|
||||
FakeImportSources(ImportSummary()), # must never run
|
||||
)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "missing.git" in body["error"]
|
||||
assert body["detail"] == {}
|
||||
if _memory_is_knowable():
|
||||
assert body["rss_mb"] is not None and body["rss_peak_mb"] is not None, body
|
||||
assert body["rss_peak_mb"] >= body["rss_mb"] - _MB_SLACK, body
|
||||
else: # a platform that cannot know still answers, with null
|
||||
assert body["rss_mb"] is None and body["rss_peak_mb"] is None, body
|
||||
|
||||
|
||||
def test_memory_pair_is_never_an_anonymous_surface(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A4's exposure guard on the wire: the status route is
|
||||
``require_admin``, and the new keys ride that gate — an anonymous
|
||||
visitor gets the pre-phase 403 with the pre-phase body, with or
|
||||
without a run in flight (the Sources view is the only place the
|
||||
number renders, and it is admin-only)."""
|
||||
anonymous = TestClient(fastapi_app)
|
||||
r = anonymous.get("/api/sync/status")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
_stub_a_one_file_run(monkeypatch, db, tmp_path)
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "success")
|
||||
|
||||
r = anonymous.get("/api/sync/status")
|
||||
assert r.status_code == 403, "no memory number on an anonymous surface"
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Unit tests: ``app.core.memory`` — process RSS helpers (phase 187, task 01).
|
||||
|
||||
Every branch of the four helpers, with the filesystem stubbed through the
|
||||
module's path constants (``PROC_STATUS_PATH`` / ``CGROUP_PEAK_PATH`` →
|
||||
``tmp_path`` files, so ``open`` is never mocked globally) and ``resource``
|
||||
stubbed through ``memory._resource`` (the guarded-import binding):
|
||||
|
||||
* the REAL ``/proc/self/status`` read on a Linux runner — ``rss_mb()`` is a
|
||||
positive int and provably comes from the ``VmRSS`` parse, not the
|
||||
fallback (the fallback is pinned to a sentinel that never surfaces);
|
||||
* the kB→MB floor arithmetic (1500 kB → 1 MB);
|
||||
* missing file, unparseable value, value-less ``VmRSS:`` line, and a file
|
||||
without any ``VmRSS`` line — each falls back to ``rss_peak_mb()``;
|
||||
* the non-Linux path: ``/proc`` open raises AND ``getrusage`` is patched →
|
||||
the proxy value; both unavailable → ``None``;
|
||||
* ``rss_peak_mb`` arithmetic, the no-``resource`` ``None``, and the
|
||||
never-raise wrapper when ``getrusage`` itself raises;
|
||||
* ``cgroup_peak_mb`` present (bytes→MB), absent, garbage, empty → the
|
||||
latter three are ``None``;
|
||||
* ``fmt_mb(None) == "?"``, ``fmt_mb(0) == "0"`` (0 is a value, not
|
||||
missing), plain ints as digits;
|
||||
* the purity contract: stdlib imports only — no ``app.`` import — and the
|
||||
docstring carries the "Pure stdlib" line the phase completion criteria
|
||||
require.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import app.core.memory as memory
|
||||
|
||||
|
||||
#: Stub whose ``getrusage`` answers with a fixed ``ru_maxrss`` (kB on
|
||||
#: Linux) so fallback assertions are deterministic.
|
||||
def _fake_resource(maxrss_kb: int) -> Any:
|
||||
return SimpleNamespace(
|
||||
RUSAGE_SELF=0,
|
||||
getrusage=lambda who: SimpleNamespace(ru_maxrss=maxrss_kb),
|
||||
)
|
||||
|
||||
|
||||
#: Stub whose ``getrusage`` raises — the never-raise contract probe.
|
||||
def _raising_resource(exc: Exception) -> Any:
|
||||
def getrusage(who: int) -> Any:
|
||||
raise exc
|
||||
|
||||
return SimpleNamespace(RUSAGE_SELF=0, getrusage=getrusage)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rss_mb — the real /proc read (Linux) and the stubbed failure modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="needs /proc/self/status")
|
||||
def test_rss_mb_reads_real_vm_rss_on_linux(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Pin the FALLBACK to a negative sentinel: a positive int can therefore
|
||||
# only come from the real VmRSS parse, not from getrusage.
|
||||
monkeypatch.setattr(memory, "rss_peak_mb", lambda: -1)
|
||||
|
||||
value = memory.rss_mb()
|
||||
|
||||
assert value is not None
|
||||
assert isinstance(value, int)
|
||||
assert value > 0
|
||||
|
||||
|
||||
def test_rss_mb_parses_vm_rss_kb_flooring_to_mb(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
status = tmp_path / "status"
|
||||
status.write_text("Name:\tpython\nVmRSS:\t 1500 kB\nVmSize:\t999999 kB\n")
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(999_999))
|
||||
|
||||
assert memory.rss_mb() == 1 # 1500 kB // 1024 — integer floor, not round
|
||||
|
||||
|
||||
def test_rss_mb_missing_file_falls_back_to_getrusage(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(9216)) # 9 MB
|
||||
|
||||
assert memory.rss_mb() == 9
|
||||
|
||||
|
||||
def test_rss_mb_unparseable_value_falls_back(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
status = tmp_path / "status"
|
||||
status.write_text("VmRSS:\tnot-a-number kB\n")
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(2048))
|
||||
|
||||
assert memory.rss_mb() == 2
|
||||
|
||||
|
||||
def test_rss_mb_value_less_line_falls_back(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
# "VmRSS:" with no value: the split()[1] raises IndexError internally,
|
||||
# the suppress turns it into the rss_peak_mb() proxy.
|
||||
status = tmp_path / "status"
|
||||
status.write_text("VmRSS:\n")
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(3072))
|
||||
|
||||
assert memory.rss_mb() == 3
|
||||
|
||||
|
||||
def test_rss_mb_file_without_vm_rss_line_falls_back(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
status = tmp_path / "status"
|
||||
status.write_text("Name:\tpython\nVmSize:\t999999 kB\nVmHWM:\t888888 kB\n")
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(1024))
|
||||
|
||||
assert memory.rss_mb() == 1
|
||||
|
||||
|
||||
def test_rss_mb_returns_none_when_proc_and_resource_both_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
|
||||
monkeypatch.setattr(memory, "_resource", None)
|
||||
|
||||
assert memory.rss_mb() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rss_peak_mb — arithmetic, the guarded import, the never-raise wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("maxrss_kb", "expected_mb"),
|
||||
[(1024, 1), (1023, 0), (1500, 1), (4_294_967_296, 4_194_304)],
|
||||
)
|
||||
def test_rss_peak_mb_arithmetic(
|
||||
monkeypatch: pytest.MonkeyPatch, maxrss_kb: int, expected_mb: int
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "_resource", _fake_resource(maxrss_kb))
|
||||
|
||||
assert memory.rss_peak_mb() == expected_mb
|
||||
|
||||
|
||||
def test_rss_peak_mb_none_without_resource_module(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "_resource", None)
|
||||
|
||||
assert memory.rss_peak_mb() is None
|
||||
|
||||
|
||||
def test_guarded_import_degrades_to_none_when_resource_is_missing(
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
# The guarded-import branch itself: a platform WITHOUT the Unix-only
|
||||
# `resource` module must still import this module, and every helper
|
||||
# must answer None (no /proc, no proxy). `sys.modules[name] = None`
|
||||
# makes `import resource` raise ImportError, so re-executing the
|
||||
# module exercises the except arm for real (no pragma).
|
||||
saved = sys.modules.get("resource")
|
||||
sys.modules["resource"] = None # type: ignore[assignment]
|
||||
try:
|
||||
importlib.reload(memory)
|
||||
|
||||
assert memory._resource is None
|
||||
assert memory.rss_peak_mb() is None
|
||||
memory.PROC_STATUS_PATH = str(tmp_path / "nope") # reset by reload
|
||||
assert memory.rss_mb() is None
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules["resource"] = saved
|
||||
else: # pragma: no cover - non-Unix runners never had it
|
||||
sys.modules.pop("resource", None)
|
||||
importlib.reload(memory) # restore the real bindings for later tests
|
||||
|
||||
|
||||
def test_module_reloads_with_real_resource_after_the_missing_module_probe() -> None:
|
||||
# Sibling pin: the finally-reload above restored the module, so the
|
||||
# guarded import took the happy arm again (order-independent: this
|
||||
# only passes if _resource is the live module on this platform).
|
||||
if not sys.platform.startswith("linux"):
|
||||
pytest.skip("resource exists on Linux")
|
||||
|
||||
assert memory._resource is not None
|
||||
|
||||
|
||||
def test_rss_peak_mb_never_raises_when_getrusage_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "_resource", _raising_resource(OSError("boom")))
|
||||
|
||||
assert memory.rss_peak_mb() is None
|
||||
|
||||
|
||||
def test_rss_peak_mb_real_call_on_linux_yields_positive_int() -> None:
|
||||
if not sys.platform.startswith("linux"):
|
||||
pytest.skip("resource.getrusage is the Linux deploy path")
|
||||
value = memory.rss_peak_mb()
|
||||
|
||||
assert value is not None
|
||||
assert isinstance(value, int)
|
||||
assert value > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cgroup_peak_mb — present / absent / garbage / empty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cgroup_peak_mb_present_converts_bytes_to_mb(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
# 2,306,867,200 bytes == 2200 MiB — the incident box's ~2.16 GB of
|
||||
# cgroup page-cache-inflated "peak" (module docstring caveat).
|
||||
peak = tmp_path / "memory.peak"
|
||||
peak.write_text("2306867200\n")
|
||||
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
|
||||
|
||||
assert memory.cgroup_peak_mb() == 2200
|
||||
|
||||
|
||||
def test_cgroup_peak_mb_absent_is_none(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(tmp_path / "memory.peak"))
|
||||
|
||||
assert memory.cgroup_peak_mb() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["max", "", " \n", "1048576 kB"])
|
||||
def test_cgroup_peak_mb_garbage_is_none(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, body: str
|
||||
) -> None:
|
||||
peak = tmp_path / "memory.peak"
|
||||
peak.write_text(body)
|
||||
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
|
||||
|
||||
assert memory.cgroup_peak_mb() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fmt_mb — the "?" rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fmt_mb_renders_none_as_question_mark_and_values_as_digits() -> None:
|
||||
assert memory.fmt_mb(None) == "?"
|
||||
assert memory.fmt_mb(0) == "0" # 0 is a VALUE, not missing
|
||||
assert memory.fmt_mb(512) == "512"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Purity contract (completion criterion: stdlib only, no app. imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_imports_stdlib_only_and_declares_pure_stdlib() -> None:
|
||||
tree = ast.parse(inspect.getsource(memory))
|
||||
imported: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported.update(alias.name.split(".")[0] for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level: # relative import — still inside the app package
|
||||
imported.add("app")
|
||||
elif node.module:
|
||||
imported.add(node.module.split(".")[0])
|
||||
|
||||
assert "app" not in imported
|
||||
# __future__, contextlib, types are static; `resource` is the guarded
|
||||
# Unix-only stdlib import — everything must stay standard library.
|
||||
assert imported <= {"__future__", "contextlib", "resource", "types"}, imported
|
||||
assert "Pure stdlib" in (memory.__doc__ or "")
|
||||
@@ -667,7 +667,12 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
success = _fn(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
i_settle = success.find("settleSyncButton(")
|
||||
i_counts = success.find("setSyncProgress(fmtSyncResult(status.detail))")
|
||||
# Phase 187 (task 03): the clause builder rides the SAME writer call
|
||||
# (the counts line + its trailing " · peak N MB", null rendering no
|
||||
# clause), so the ordering pin reads the new argument list.
|
||||
i_counts = success.find(
|
||||
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
|
||||
)
|
||||
assert -1 < i_settle < i_counts, (
|
||||
"the counts line (the box's result line) follows the settle label"
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import UTC, datetime
|
||||
@@ -54,6 +55,11 @@ from tests.fakes import FakeEmbedder
|
||||
#: (``Settings().import_extension_set`` — now seventeen formats).
|
||||
DEFAULT_EXTS = frozenset({".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"})
|
||||
|
||||
#: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
|
||||
#: allows for an MB field — ``?`` when the platform cannot know, else a
|
||||
#: positive integer (never ``None``, never a float or a negative).
|
||||
MB = r"(?:\?|[1-9][0-9]*)"
|
||||
|
||||
|
||||
class _PoisonEmbedder(FakeEmbedder):
|
||||
"""Fails (like a real endpoint) on any text containing 'poison'."""
|
||||
@@ -1001,7 +1007,10 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
and the phase-151 ``pdf_images_deduped`` / ``pdf_images_blank_skipped``
|
||||
counters (the parser's per-PDF drop counts, next to
|
||||
``pdf_pictorial_pages``) sit after ``dates_updated``, before
|
||||
``formats``."""
|
||||
``formats``. Phase 187 (task 02) APPENDS a trailing ``rss_mb=``
|
||||
(LOCKED A2 — the memory the run sits at, ``?`` when unknowable); the
|
||||
pre-phase text up to and including ``formats=…`` is byte-identical.
|
||||
"""
|
||||
s = ImportSummary()
|
||||
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
|
||||
s.summaries, s.summary_errors = 2, 1
|
||||
@@ -1019,7 +1028,7 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
s.log()
|
||||
line = caplog.records[-1].getMessage()
|
||||
assert line == (
|
||||
assert line.startswith(
|
||||
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
|
||||
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
|
||||
"dates_updated=0 images_failed=0 svg_indexed=4 image_context_backfilled=2 "
|
||||
@@ -1027,6 +1036,9 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
"pdf_images_deduped=3 pdf_images_blank_skipped=1 "
|
||||
"formats=yaml:2,md:1"
|
||||
)
|
||||
# Phase 187 (task 02): the ONE appended field, `?` (unknowable) or a
|
||||
# positive integer MB — never `None`, never a negative or a float.
|
||||
assert re.fullmatch(rf".* rss_mb={MB}", line), line
|
||||
|
||||
|
||||
def test_prune_removes_files_now_excluded_by_format_filter(db, tmp_path: Path) -> None:
|
||||
@@ -1082,7 +1094,10 @@ def test_formats_counter_counts_extensionless_name_token(
|
||||
for r in caplog.records
|
||||
if "import: summary files=" in r.getMessage()
|
||||
)
|
||||
assert line.endswith("formats=dockerfile:1,md:1")
|
||||
# Phase 187 (task 02): ``formats=`` is no longer the LAST field
|
||||
# (`` rss_mb=…`` follows), but its position and value are intact.
|
||||
assert " formats=dockerfile:1,md:1" in line
|
||||
assert re.search(rf"formats=dockerfile:1,md:1 rss_mb={MB}$", line), line
|
||||
assert "unknown" not in line
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
+485
-11
@@ -203,6 +203,10 @@ RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: Phase 187 (task 02): what :func:`app.core.memory.fmt_mb` may render for
|
||||
#: an MB field — ``?`` when the platform cannot know, else a positive int.
|
||||
MB = r"(?:\?|[1-9][0-9]*)"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
@@ -1112,9 +1116,13 @@ def test_sources_js_renders_result_line_and_banner_directly() -> None:
|
||||
for other subscribers."""
|
||||
js = _text(SOURCES_JS)
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert "setSyncProgress(fmtSyncResult(status.detail))" in success, (
|
||||
"the counts live in status.detail — a bare status renders all zeros"
|
||||
)
|
||||
# Phase 187 (task 03): the counts line keeps its builder and gains
|
||||
# the TRAILING peak clause — a null pair appends the empty string, so
|
||||
# every pre-phase line stays byte-identical.
|
||||
assert (
|
||||
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
|
||||
in success
|
||||
), "the counts live in status.detail — a bare status renders all zeros"
|
||||
assert "loadTree()" in success, (
|
||||
"the catalog re-fetches live on a successful sync (phase 97: the tree)"
|
||||
)
|
||||
@@ -1319,14 +1327,21 @@ def fresh_sync_status() -> Iterator[None]:
|
||||
|
||||
Phase 134 (task 01): the module ``_cancel_event`` joins the reset —
|
||||
a stale (set) event from a previous test's cancelled run would
|
||||
otherwise bleed into the next run's boundary checks."""
|
||||
otherwise bleed into the next run's boundary checks.
|
||||
|
||||
Phase 187 (task 03): the memory-sampling guard (``_rss_sampled_at``)
|
||||
joins the reset — it is module-global state beside ``_status``, and a
|
||||
test that pins "the per-file hook sampled" has to know whether the
|
||||
guard says "never read" or "still inside the 5 s window"."""
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
sync_api._rss_sampled_at = float("-inf")
|
||||
yield
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
sync_api._rss_sampled_at = float("-inf")
|
||||
|
||||
|
||||
class _GatedClone:
|
||||
@@ -1567,9 +1582,11 @@ def test_idle_status_pins_full_shape_including_progress_keys(
|
||||
) -> None:
|
||||
"""Idle: the three phase-64 progress keys ride along as null/0/0,
|
||||
the two phase-129 chunk keys as 0/0, the two phase-152 image keys
|
||||
as 0/0, the four phase-98 phase keys as null/0/0/0, and EVERY
|
||||
pre-existing key is unchanged — the full response dict is pinned,
|
||||
so the current UI and every existing consumer keep working."""
|
||||
as 0/0, the four phase-98 phase keys as null/0/0/0, the two phase-187
|
||||
memory keys as null/null (NO reading is not 0 — and the Sources page
|
||||
renders no peak clause for it), and EVERY pre-existing key is
|
||||
unchanged — the full response dict is pinned, so the current UI and
|
||||
every existing consumer keep working."""
|
||||
assert sync_api.sync_status() == {
|
||||
"state": "idle",
|
||||
"started_at": None,
|
||||
@@ -1592,6 +1609,10 @@ def test_idle_status_pins_full_shape_including_progress_keys(
|
||||
"current_summary": None,
|
||||
"summaries_done": 0,
|
||||
"summaries_total": 0,
|
||||
# Phase 187 (task 03): the memory keys — null/null idle (never
|
||||
# sampled: never 0, never an invented number).
|
||||
"rss_mb": None,
|
||||
"rss_peak_mb": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -2562,10 +2583,15 @@ def test_cancel_endpoint_stops_the_in_flight_run(
|
||||
# through the closure in flight is gone; the FILE counts above
|
||||
# keep their final values).
|
||||
assert s["images_done"] == 0 and s["images_total"] == 0
|
||||
# PLAN §9: the ample-logging line for the converted cancel.
|
||||
# PLAN §9: the ample-logging line for the converted cancel. Phase
|
||||
# 187 (task 02) APPENDS the memory fields (LOCKED A2) — the pre-phase
|
||||
# prefix stays byte-identical, the tail is `?` or a positive int.
|
||||
assert any(
|
||||
r.getMessage()
|
||||
== "sync: cancelled by user state=cancelled files_done=1 files_total=3"
|
||||
re.fullmatch(
|
||||
r"sync: cancelled by user state=cancelled files_done=1 files_total=3"
|
||||
rf" rss_mb={MB} rss_peak_mb={MB}",
|
||||
r.getMessage(),
|
||||
)
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
@@ -3091,7 +3117,9 @@ def test_sources_js_apply_sync_cancelled_is_neutral() -> None:
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "applySyncCancelled")
|
||||
assert 'settleSyncButton("Sync sources")' in body
|
||||
assert 'setSyncProgress("Sync cancelled")' in body
|
||||
# Phase 187 (task 03): the neutral copy keeps its exact wording and
|
||||
# the peak clause trails it (a null pair appends the empty string).
|
||||
assert 'setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status))' in body
|
||||
assert "hideSyncError()" in body
|
||||
assert "emitSyncStatus(status)" in body
|
||||
assert "showSyncModal" not in body, "cancel is intent — no modal"
|
||||
@@ -3123,3 +3151,449 @@ def test_sources_js_section_header_documents_the_cancel_contract() -> None:
|
||||
assert "5. sync cancelled" in header, "the tree's cancelled branch (the 5th)"
|
||||
assert "8. both idle" in header, "the tree is now 8 branches"
|
||||
assert "#sync-cancel-btn" in header, "the control's contract is documented"
|
||||
|
||||
|
||||
# ---------- phase 187 (task 03): the run's memory on the status ----------
|
||||
#
|
||||
# The owner incident (2026-10-15): "how much RAM did that sync use?" was
|
||||
# answered by reading systemd unit-teardown lines out of three boots —
|
||||
# and the boot that killed the VM had none. Tasks 01/02 made the log
|
||||
# carry the number; task 03 puts the SAME pair on the status object, so
|
||||
# the page that started the run answers without a shell. The pins below
|
||||
# hold the three things that make that honest: the pair is ADDITIVE (the
|
||||
# key set is exactly the old set plus two), it is SAMPLED from ONE place
|
||||
# (forced at the run start / every phase transition / every terminal,
|
||||
# throttled to one read per 5 s in the per-file hook — LOCKED A4), and a
|
||||
# reading that does not exist stays null (never 0, never invented).
|
||||
|
||||
|
||||
#: The status payload's key set BEFORE phase 187 — the additive pin's
|
||||
#: left-hand side: the payload is exactly this set plus
|
||||
#: :data:`_MEMORY_KEYS`, nothing else, forever.
|
||||
_PRE_187_STATUS_KEYS = frozenset(
|
||||
{
|
||||
"state",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"detail",
|
||||
"error",
|
||||
"current_file",
|
||||
"files_done",
|
||||
"files_total",
|
||||
"current_chunk",
|
||||
"chunks_total",
|
||||
"images_done",
|
||||
"images_total",
|
||||
"phase",
|
||||
"current_summary",
|
||||
"summaries_done",
|
||||
"summaries_total",
|
||||
}
|
||||
)
|
||||
|
||||
#: Phase 187 (task 03): the two additive memory keys.
|
||||
_MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
|
||||
|
||||
|
||||
def test_status_key_set_is_the_old_set_plus_the_memory_pair(
|
||||
fresh_sync_status: None,
|
||||
) -> None:
|
||||
"""The key-set pin (D4: "Additive only — every pre-existing key
|
||||
keeps its name, type, and meaning"): the payload is EXACTLY the
|
||||
pre-phase-187 set ``∪ {rss_mb, rss_peak_mb}`` — a fifth sub-position
|
||||
or a renamed field fails here even when the value assertions pass."""
|
||||
keys = set(sync_api.sync_status())
|
||||
assert keys == _PRE_187_STATUS_KEYS | _MEMORY_KEYS, keys
|
||||
assert _MEMORY_KEYS.isdisjoint(_PRE_187_STATUS_KEYS)
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
"""``sync_api.time`` stubbed: the sampler is the module's only
|
||||
``time.monotonic()`` reader, so the 5 s guard is assertable without
|
||||
sleeping (the test moves the clock, never the wall)."""
|
||||
|
||||
def __init__(self, start: float = 10_000.0) -> None:
|
||||
self.now = start
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class _StubMemory:
|
||||
"""The two ``app.core.memory`` reads as a scripted pair-per-read
|
||||
source: sample *n* returns ``readings[n]`` (the last pair repeats)
|
||||
and :attr:`reads` counts the samples, so "sampled at most once per
|
||||
5 s" is a number and not a comment.
|
||||
|
||||
Both helpers are patched on ``app.api.sync`` (the module under test
|
||||
imports them by name), which is also what proves the sampler reads
|
||||
them from ONE place: a call counter that disagrees with
|
||||
``_status`` is a second, unsampled write path."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
readings: Sequence[tuple[int | None, int | None]],
|
||||
) -> None:
|
||||
self.readings = list(readings)
|
||||
self.reads = 0
|
||||
monkeypatch.setattr(sync_api, "rss_mb", self._current)
|
||||
monkeypatch.setattr(sync_api, "rss_peak_mb", self._peak)
|
||||
|
||||
def _pair(self) -> tuple[int | None, int | None]:
|
||||
return self.readings[min(self.reads, len(self.readings) - 1)]
|
||||
|
||||
def _current(self) -> int | None:
|
||||
return self._pair()[0]
|
||||
|
||||
def _peak(self) -> int | None:
|
||||
value = self._pair()[1]
|
||||
self.reads += 1 # the PAIR is one sample — counted when it completes
|
||||
return value
|
||||
|
||||
|
||||
def test_sample_rss_stores_both_readings_from_one_sample(
|
||||
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_sample_rss`` is the ONE writer of the pair: both fields land
|
||||
from the same read, so the status and the lifecycle line can never
|
||||
report two different instants."""
|
||||
monkeypatch.setattr(sync_api, "time", _FakeClock())
|
||||
memory = _StubMemory(monkeypatch, [(412, 1_936)])
|
||||
|
||||
sync_api._sample_rss(force=True)
|
||||
|
||||
assert sync_api._status.rss_mb == 412
|
||||
assert sync_api._status.rss_peak_mb == 1_936
|
||||
assert memory.reads == 1
|
||||
|
||||
|
||||
def test_sample_rss_throttles_the_unforced_call_and_force_always_reads(
|
||||
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""LOCKED A4 / D3, pinned on the guard itself: the FIRST call reads
|
||||
(never sampled), the next one inside ``_RSS_SAMPLE_INTERVAL_S`` is a
|
||||
NO-OP, one past the interval reads again, and ``force=True`` reads
|
||||
regardless AND re-arms the window (which is what keeps the 27k-file
|
||||
walk at ~600 reads)."""
|
||||
clock = _FakeClock(10_000.0)
|
||||
monkeypatch.setattr(sync_api, "time", clock)
|
||||
memory = _StubMemory(
|
||||
monkeypatch, [(100, 200), (101, 201), (102, 202), (103, 203), (104, 204)]
|
||||
)
|
||||
assert sync_api._RSS_SAMPLE_INTERVAL_S == 5.0, "the phase's sampling window"
|
||||
|
||||
sync_api._sample_rss() # never sampled → reads
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (100, 200)
|
||||
|
||||
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S - 0.001
|
||||
sync_api._sample_rss() # inside the window → no-op
|
||||
assert memory.reads == 1
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (100, 200)
|
||||
|
||||
clock.now += 0.002 # the window closed
|
||||
sync_api._sample_rss()
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (101, 201)
|
||||
|
||||
sync_api._sample_rss(force=True) # forced, immediately → reads
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (102, 202)
|
||||
sync_api._sample_rss() # …and the forced read re-armed the window
|
||||
assert memory.reads == 3, "the forced sample closed the window it opened"
|
||||
|
||||
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S
|
||||
sync_api._sample_rss()
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (103, 203)
|
||||
assert memory.reads == 4
|
||||
|
||||
|
||||
def test_sample_rss_keeps_none_as_unknown(
|
||||
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A platform that cannot know (no ``/proc``, no ``resource``) stays
|
||||
``None`` on the status — never 0, never a plausible number. The wire
|
||||
form is JSON null and the UI renders no clause for it (the same
|
||||
``?``-is-not-0 rule :func:`app.core.memory.fmt_mb` holds for the
|
||||
log)."""
|
||||
monkeypatch.setattr(sync_api, "time", _FakeClock())
|
||||
_StubMemory(monkeypatch, [(None, None)])
|
||||
|
||||
sync_api._sample_rss(force=True)
|
||||
|
||||
assert sync_api._status.rss_mb is None
|
||||
assert sync_api._status.rss_peak_mb is None
|
||||
body = sync_api.sync_status()
|
||||
assert body["rss_mb"] is None and body["rss_peak_mb"] is None
|
||||
|
||||
|
||||
def test_sample_rss_never_publishes_a_peak_below_the_current_reading(
|
||||
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The pair's invariant, pinned at the ONE sampling point (task 04).
|
||||
|
||||
``VmRSS`` and ``ru_maxrss`` are two kernel counters read at two
|
||||
instants, both floored to whole MB, and the high-water refreshes
|
||||
LAZILY — so a growing run really does report ``rss_mb`` above
|
||||
``rss_peak_mb`` (measured on this repo's own suite at 164 vs 162).
|
||||
A "peak" under the process's own current footprint is a FALSE
|
||||
number, not a conservative one, so the sampler lifts it to the
|
||||
current reading: a lower bound on the truth, never an invention.
|
||||
Without this the invariant every consumer (both lifecycle lines, the
|
||||
status payload, the page's clause) now relies on would be a coin
|
||||
flip — and the phase's own integration gate proved it."""
|
||||
monkeypatch.setattr(sync_api, "time", _FakeClock())
|
||||
_StubMemory(monkeypatch, [(412, 410)]) # the lazy-kernel case
|
||||
|
||||
sync_api._sample_rss(force=True)
|
||||
|
||||
assert sync_api._status.rss_mb == 412
|
||||
assert sync_api._status.rss_peak_mb == 412, "the lagging peak is raised"
|
||||
assert sync_api.sync_status()["rss_peak_mb"] == 412, "the wire agrees"
|
||||
|
||||
|
||||
def test_sample_rss_does_not_fabricate_an_unknown_peak(
|
||||
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The normalization's edge: an UNKNOWABLE peak stays ``None``.
|
||||
|
||||
Lifting a lagging reading is a bound on a real measurement; inventing
|
||||
a peak from the current reading on a platform where
|
||||
:func:`app.core.memory.rss_peak_mb` answered ``None`` would be the
|
||||
invented number the phase forbids (``null`` renders no clause, ``0``
|
||||
is a value). So the clamp needs BOTH readings to be real."""
|
||||
monkeypatch.setattr(sync_api, "time", _FakeClock())
|
||||
_StubMemory(monkeypatch, [(412, None)])
|
||||
|
||||
sync_api._sample_rss(force=True)
|
||||
|
||||
assert sync_api._status.rss_mb == 412
|
||||
assert sync_api._status.rss_peak_mb is None
|
||||
|
||||
|
||||
def test_run_samples_at_start_phase_transitions_and_terminal(
|
||||
fresh_sync_status: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The sampling POINTS, walked through the real runner: run start →
|
||||
``import`` → ``overview`` → ``summaries`` → the success terminal, one
|
||||
forced read each, and the per-file progress hook's throttled call
|
||||
adds NOTHING inside the window the run start just armed (LOCKED A4).
|
||||
|
||||
The last pair is the number the incident was about: the terminal
|
||||
KEEPS it (the phase-64 keep-final-values convention) and the
|
||||
``sync: done`` line logs the very same pair the payload reports —
|
||||
one reading, two surfaces, no disagreement between the page and the
|
||||
log an operator could be argued with."""
|
||||
clock = _FakeClock(10_000.0)
|
||||
monkeypatch.setattr(sync_api, "time", clock)
|
||||
# run start, import, overview, summaries, done (the hook is throttled).
|
||||
memory = _StubMemory(
|
||||
monkeypatch,
|
||||
[(10, 20), (11, 21), (12, 22), (13, 23), (14, 4_321)],
|
||||
)
|
||||
clone = _GatedClone(threading.Event(), threading.Event())
|
||||
fake_import = _GatedImport(
|
||||
ImportSummary(files=3, added=1, unchanged=2),
|
||||
threading.Event(),
|
||||
threading.Event(),
|
||||
)
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
thread, errors = _start_run()
|
||||
try:
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
# The run start sampled BEFORE any pipeline step: a run that
|
||||
# fails in the clone already has a number on the status.
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert (s["rss_mb"], s["rss_peak_mb"]) == (10, 20)
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
# Import phase: the transition forced the second read, and
|
||||
# the hook's own call (this fake fires one file) is inside
|
||||
# the window — still 2 reads, not 3 (A4 in numbers).
|
||||
s = sync_api.sync_status()
|
||||
assert s["phase"] == "import"
|
||||
assert fake_import.hook_calls == [("repo", "notes/deep.md", 1, 3)]
|
||||
assert (s["rss_mb"], s["rss_peak_mb"]) == (11, 21)
|
||||
assert memory.reads == 2, "the per-file hook never bypasses the guard"
|
||||
fake_import.release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
fake_import.release.set()
|
||||
thread.join(10.0)
|
||||
assert errors == [], f"the runner raised: {errors!r}"
|
||||
|
||||
# The terminal pair is the FINAL reading, kept next to the counts,
|
||||
# and it survives the run (the page shows it after the settle).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "success"
|
||||
assert (s["rss_mb"], s["rss_peak_mb"]) == (14, 4_321)
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
|
||||
done = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.getMessage().startswith("sync: done detail=")
|
||||
]
|
||||
assert len(done) == 1, done
|
||||
assert done[0].endswith("rss_mb=14 rss_peak_mb=4321"), done[0]
|
||||
|
||||
|
||||
def test_per_file_hook_samples_once_the_window_closes(
|
||||
fresh_sync_status: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The other half of D3: the number on the page MOVES. The hook
|
||||
fires once per file, and once the 5 s window has closed the next
|
||||
file's call re-reads — so a long walk reports the memory climbing
|
||||
(PLAN §7.4's never-stale contract) at ~600 reads per 27k files
|
||||
instead of 27k."""
|
||||
clock = _FakeClock(10_000.0)
|
||||
monkeypatch.setattr(sync_api, "time", clock)
|
||||
memory = _StubMemory(monkeypatch, [(100, 200), (110, 210), (140, 260)])
|
||||
clone = _GatedClone(threading.Event(), threading.Event())
|
||||
fake_import = _GatedImport(
|
||||
ImportSummary(files=1, added=1),
|
||||
threading.Event(),
|
||||
threading.Event(),
|
||||
)
|
||||
# The fake fires ONE file, parks, then returns — so the hook is
|
||||
# called once mid-run and the test calls it a second time with the
|
||||
# window closed (the second file of a real walk).
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
|
||||
|
||||
thread, errors = _start_run()
|
||||
try:
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
s = sync_api.sync_status()
|
||||
assert s["phase"] == "import"
|
||||
assert (s["rss_mb"], s["rss_peak_mb"]) == (110, 210) # the transition
|
||||
assert memory.reads == 2, "the hook's call inside the window read nothing"
|
||||
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S + 1.0
|
||||
sync_api._sample_rss() # the NEXT file's hook call
|
||||
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (140, 260)
|
||||
fake_import.release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
fake_import.release.set()
|
||||
thread.join(10.0)
|
||||
assert errors == [], f"the runner raised: {errors!r}"
|
||||
assert memory.reads >= 3
|
||||
|
||||
|
||||
# ---------- phase 187 (task 03): the peak clause on the result line ------
|
||||
|
||||
|
||||
def test_sources_js_peak_clause_is_a_trailing_clause_with_a_null_guard() -> None:
|
||||
"""The frontend SOURCE pin (the browser behavior is
|
||||
tests/e2e/test_sync_peak_memory_visible.py, phase 187 task 04):
|
||||
|
||||
* ONE builder, reading ``status.rss_peak_mb ?? status.rss_mb`` (the
|
||||
high-water mark, the current reading as the fall-back);
|
||||
* a non-number (null / undefined / garbage) renders the EMPTY string
|
||||
— never ``peak ? MB``, never ``peak null MB``;
|
||||
* it is APPENDED to the two result strings that already exist
|
||||
(``fmtSyncResult(...)``, ``"Sync cancelled"``) through the single
|
||||
box writer — no new control, no new live region, no replaced
|
||||
state string;
|
||||
* NOTHING else reads it: the running label, the failed box (the
|
||||
banner + modal own the error, byte-identical per phase 150) and
|
||||
the upload settles never grow a clause;
|
||||
* the shell markup is untouched — the box is still one text span."""
|
||||
js = _text(SOURCES_JS)
|
||||
builder = _body(js, "fmtSyncPeakClause")
|
||||
assert "status.rss_peak_mb ?? status.rss_mb" in builder
|
||||
assert 'return "";' in builder, "null renders NOTHING"
|
||||
assert "` · peak ${peak} MB`" in builder, "the clause's exact shape"
|
||||
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert (
|
||||
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
|
||||
in success
|
||||
)
|
||||
cancelled = _body(js, "applySyncCancelled")
|
||||
assert 'setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status))' in cancelled
|
||||
|
||||
for untouched in (
|
||||
"enterSyncRunningState",
|
||||
"fmtSyncPhaseLabel",
|
||||
"fmtSyncLabel",
|
||||
"fmtSyncResult",
|
||||
"setSyncLabel",
|
||||
"setSyncProgress",
|
||||
"applySyncFailure",
|
||||
"applySyncIdle",
|
||||
):
|
||||
assert "fmtSyncPeakClause" not in _body(js, untouched), (
|
||||
f"{untouched} must not grow a clause — the terminal result "
|
||||
"line is the only surface (D4)"
|
||||
)
|
||||
|
||||
html = _text(SHELL_HTML)
|
||||
assert '<span class="sync-progress-text" id="sync-progress-text"></span>' in html
|
||||
assert 'id="sync-progress-peak"' not in html, "no new element for the clause"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_sources_js_peak_clause_renders_under_node() -> None:
|
||||
"""The REAL extracted ``fmtSyncResult`` + ``fmtSyncPeakClause`` under
|
||||
node, composed exactly as ``applySyncSuccess`` composes them: the
|
||||
counts line keeps its exact wording and the clause trails it
|
||||
(``3 added · 2 unchanged · peak 1936 MB``); a null peak falls back to
|
||||
the current reading; BOTH null (idle, a pre-phase-187 run, the
|
||||
upload job, a platform that cannot know) render the counts line with
|
||||
NO clause — never ``peak null MB``; ``0`` is a VALUE, not missing
|
||||
(the ``fmt_mb(0) == "0"`` rule, mirrored); and a negative or
|
||||
non-numeric value renders nothing."""
|
||||
src = (
|
||||
_body(_text(SOURCES_JS), "fmtSyncResult")
|
||||
+ "\n"
|
||||
+ _body(_text(SOURCES_JS), "fmtSyncPeakClause")
|
||||
)
|
||||
driver_expr = (
|
||||
"const CASES = ["
|
||||
"{ detail: { added: 3, unchanged: 2 }, rss_mb: 512, rss_peak_mb: 1936 },"
|
||||
"{ detail: { added: 0, unchanged: 1 }, rss_mb: 512, rss_peak_mb: null },"
|
||||
"{ detail: { added: 0, unchanged: 1 }, rss_mb: null, rss_peak_mb: null },"
|
||||
"{ detail: { added: 1 } },"
|
||||
"{ detail: { added: 2, pruned: 4 }, rss_mb: 0, rss_peak_mb: 0 },"
|
||||
"{ detail: { added: 1 }, rss_peak_mb: -5 },"
|
||||
"{ detail: { added: 1 }, rss_peak_mb: 'big' },"
|
||||
"];\n"
|
||||
"const LINES = CASES.map("
|
||||
" (s) => fmtSyncResult(s.detail) + fmtSyncPeakClause(s)\n"
|
||||
");\n"
|
||||
"const CANCELLED = CASES.map("
|
||||
" (s) => 'Sync cancelled' + fmtSyncPeakClause(s)\n"
|
||||
");\n"
|
||||
"console.log(JSON.stringify({ LINES, CANCELLED }));"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "-e", f"{src}\n{driver_expr}"], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
out = json.loads(proc.stdout)
|
||||
assert out["LINES"] == [
|
||||
"3 added · 2 unchanged · peak 1936 MB",
|
||||
"0 added · 1 unchanged · peak 512 MB", # the current-reading fallback
|
||||
"0 added · 1 unchanged", # both null → no clause at all
|
||||
"1 added", # idle / pre-phase / upload status: no keys, no clause
|
||||
"2 added · 4 pruned · peak 0 MB", # 0 is a value, not missing
|
||||
"1 added", # a negative reading is not a number to show
|
||||
"1 added", # garbage is not a number either
|
||||
], out
|
||||
assert out["CANCELLED"] == [
|
||||
"Sync cancelled · peak 1936 MB",
|
||||
"Sync cancelled · peak 512 MB",
|
||||
"Sync cancelled",
|
||||
"Sync cancelled",
|
||||
"Sync cancelled · peak 0 MB",
|
||||
"Sync cancelled",
|
||||
"Sync cancelled",
|
||||
], out
|
||||
|
||||
Reference in New Issue
Block a user