phase: 129_sync_chunk_progress
Build and Push Containers / build-and-push-app (push) Successful in 8m23s
Build and Push Containers / build-and-push-db (push) Successful in 40s

All green. Final report:

**Phase 129 final verification pass — 100% green** (previous work intact; this pass re-verified everything and fixed two pre-existing README inconsistencies)

- Verified on-spec: `LLMClient.embed` per-batch `on_progress` hook; importer `on_chunk_progress` plumbing (text/image/PDF branches; summary embed excluded); `SyncStatus.current_chunk/chunks_total` reset+clear contract; UI `.sync-label-chunk` pinned part with untruncated `title`/`#sync-result`
- `uv run pytest --cov=app -q`: exit 0, ~3041 passed, 0 failed, `app/` coverage **99%** (>90% gate)
- `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` (isolated, DB up): **2 passed** (endpoint chunk climb with true `chunk_document` total + UI `· chunk n/m` part)
- `uv run ruff check .`: all checks passed · `uv run pyright`: 0 errors, 0 warnings
- Completion criteria: all 5 ✅ (incl. no behavior change in completed phases — full suite green, CLI/chat no-op paths byte-identical)
- Docs: README.md fixed 2 pre-existing inconsistencies (upload section claimed "unpacks + scans immediately" — phase 90 made it unpack+register only, scan deferred to Sync; troubleshooting claimed "120s client timeout" — actual guard is the 300 s pre-token `TURN_TIMEOUT_MS`). Phase-129 label behavior already documented. AGENTS.md re-verified consistent — no changes
- Removed stray untracked `NOTES.md` (8-byte artifact); no live-infra changes; no commits — all changes left in working tree for the harness
- Next pending phase: `130_chat_answer_clusters`
This commit is contained in:
2026-09-28 03:38:14 -04:00
parent 1139f007cf
commit 2d0f608246
53 changed files with 3590 additions and 157 deletions
@@ -0,0 +1,36 @@
# Phase 129 — The sync label shows what chunk we're on out of how many
**Source:** `TODO.md` L5 — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (ASSUMPTION A6, owner-confirmed 2026-09-27: "chunk" = the current file's EMBEDDING CHUNKS; position reported at BATCH granularity — `n` = first chunk of the in-flight token-budgeted embed batch — because the transport embeds batches per HTTP call and per-chunk calls would be a latency regression; the summary-chunk embed is excluded from the counter; label `Syncing… <file> (2/3) · chunk 14/38`).
**Story:** n/a (TODO-derived phase, Protocol B append; a NEW feature — distinct from phase 128's mobile fix, per the owner).
**Context:** `app/rag/llm.py` — `LLMClient.embed(texts)` (L346: iterates `self._batch_texts(texts)` — token-budgeted batches, `BOR_EMBED_BATCH_SIZE` cap, `_REQUEST_TOKEN_BUDGET` ≈ 2800 chars), `_embed_batch` (L289: the halving backstop on `_TooLarge`), `self.embed_batches` counter (L249); `app/rag/importer.py` — `import_sources` (L408, the per-file `progress(source, rel, done, total)` hook fired BEFORE each `_index_file`), `_index_file` (L647 — text branch → `_replace_content_chunks`, image branch → `_index_image_file`, PDF branch → `_index_pdf_file`), `_replace_content_chunks` (L603 — chunks the content, `llm.embed([c.content for c in doc.chunks])` in ONE call, token-cap re-chunk retry loop), `_store_summary` (embeds the ONE summary chunk — EXCLUDED from the counter); `app/api/sync.py` — `SyncStatus` (the phase-64/98 progress fields: `current_file`, `files_done/total`, `phase`, `current_summary`, `summaries_done/total`; `_run_sync`'s `_hook` closure convention; terminal-state clearing); `frontend/assets/sources.js` — the phase-128 part builders (`setSyncLabel`, `.sync-label-main` / `.sync-label-count`); `tests/e2e/slow_llm.py` + `tests/e2e/test_sync_summary_visibility.py` — the slow-proxy sampling pattern.
## Objective
While a file is being imported, the sync status (endpoint + button label) shows the file's embedding-chunk position: `Syncing… <file> (2/3) · chunk 14/38` — 14 = the first chunk of the embed batch in flight, 38 = the file's total content chunks. The position climbs through the file's batches and is cleared when the file (or the run) moves on. Everything that passes no callback stays a zero-cost no-op (the CLI, the chat path).
## Dependencies
- `128_sync_label_mobile` (todo) — the label's part structure (`.sync-label-main` + pinned count span) is the carrier; this phase adds the pinned chunk span.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The embed hook (task 01):** `LLMClient.embed(texts, on_progress: Callable[[int, int], None] | None = None)` — before EACH top-level batch request, `on_progress(first_1based, len(texts))` where `first_1based` = 1 + the cumulative chunk counts of the already-sent batches. `None` (every existing caller — the chat retrieval path, `embed_one`, the summarizer, the CLI) is a zero-cost guarded no-op. The `_embed_batch` halving backstop does NOT sub-report — the top-level loop reports batch starts; a halved batch means the position is approximate (document in the docstring; the backstop is rare by construction).
- **Importer plumbing (task 02):** `import_sources(..., on_chunk_progress: Callable[[int, int], None] | None = None)` — a new keyword-only param, plumbed: `import_sources` → `_index_file(..., on_chunk=...)` (all three branches: text, image, PDF — the PDF branch's content embed is the same `_replace_content_chunks` call; the image branch's ONE description-chunk embed reports `1/1` — acceptable, it IS the file's only chunk) → `_replace_content_chunks(..., on_chunk=...)` → `llm.embed([...], on_progress=on_chunk)`. The token-cap re-chunk retry re-embeds with the NEW (smaller-target) chunk list — the hook naturally reports against the new total (no special case). `_store_summary`'s single summary-chunk embed NEVER receives the hook (the counter is content chunks only — no `1/1` noise after the real progress ends). The `scripts/import_docs.py` CLI passes nothing — byte-identical.
- **Sync status (task 03):** `SyncStatus` gains `current_chunk: int = 0` and `chunks_total: int = 0` (documented: the current file's embed-batch position — 0/0 before the first batch and in terminal states). `_run_sync`: reset to 0/0 at run start (with the other progress fields); a closure `_chunk_hook(chunk, total)` (the file's `_hook` convention) sets them; passed as `on_chunk_progress` to `import_sources`. CLEARED (0/0) when the import phase ends — set at the `phase = "overview"` and `phase = "summaries"` transitions AND in both terminal states (the `current_file` clearing points — same convention, same spots). `GET /api/sync/status` exposes both fields (0/0 idle — the dataclass defaults). The `SyncStatus` docstring + module docstring get the chunk-position paragraph (the phase-64/98 style).
- **UI (task 04):** `sources.js` — the import-phase label gains a THIRD pinned part `.sync-label-chunk` (markup in `index.html`'s `#sync-label`, `hidden` by default): when `status.chunks_total > 0` the import fall-through emits `· chunk ${status.current_chunk}/${status.chunks_total}` into it; every other phase/state leaves it hidden/empty (the summaries phase has its own `(n/m)` — no chunk part; the prelude has no file yet). The UNTRUNCATED concatenation (main + count + chunk part) rides the button `title` + `#sync-result` (A4). The pinned unit tests (`tests/unit/test_frontend_sync_upload.py`) gain the chunk-part pins.
## Tasks
1. `01_llm_embed_hook.md` — `LLMClient.embed`'s optional per-batch `on_progress`.
2. `02_importer_chunk_progress.md` — `import_sources` → `_index_file` → `_replace_content_chunks` → `embed` plumbing.
3. `03_sync_status_chunk_fields.md` — `SyncStatus` fields, the closure, clearing, the endpoint.
4. `04_ui_chunk_part.md` — the label's chunk part + unit pins.
5. `05_e2e_chunk_progress.md` — dedicated Playwright E2E, run in isolation.
## Testing & Quality
- Unit/integration: the embed hook's call sequence (batch boundaries, `None` no-op, halving-approximate documented); the importer plumbing (hook fires per file with its own total; the CLI path unchanged; the summary-chunk embed never fires it); the sync status (fields in the endpoint JSON, reset per run, cleared at phase transitions + terminal, 0/0 idle).
- Coverage: **>90%** on new/modified code (`app/`).
- E2E: one dedicated Playwright file, run in isolation, slow-LLM sampling.
## Completion Criteria
- [ ] `GET /api/sync/status` reports `current_chunk` climbing 1 → N with `chunks_total` = the file's true content-chunk count while a file imports (E2E, sampled)
- [ ] the rendered mobile/desktop label shows `· chunk n/m` during the import phase only; the untruncated text rides `title` + `#sync-result`
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%
- [ ] `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` green in isolation
- [ ] no behavior change in completed phases (chat embed path, CLI import, the phase-64/98 label contract minus the new part)
@@ -0,0 +1,28 @@
# Task 01 — `LLMClient.embed`'s optional per-batch `on_progress` hook
**Phase:** `129_sync_chunk_progress` · **Source:** `TODO.md:5` — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (A6: batch-granularity chunk position)
## Objective
`embed` can report, before each top-level batch request, which chunk of the input list starts that batch — `on_progress(first_1based, total)` — so a caller can render `chunk n/m`. No callback → zero-cost no-op, byte-identical behavior for every existing caller.
## Work
1. `app/rag/llm.py` — `LLMClient.embed` (L346): new keyword-only param `on_progress: Callable[[int, int], None] | None = None`. In the batch loop, BEFORE `await self._embed_batch(chunk)` (and before the try/except, so a failing batch still announced its start):
```python
if on_progress is not None:
on_progress(len(out) + 1, len(texts)) # first chunk of THIS batch (1-based), total
```
(`len(out)` = chunks already embedded in earlier batches — the accumulator's length is exactly that, since each batch's vectors extend it 1:1.)
Docstring: the contract — called once per TOP-LEVEL batch, before the request, with `(first_chunk_1based, total_chunks)`; the `_embed_batch` halving backstop does not sub-report (a halved batch makes the position approximate — documented; the backstop is rare by construction, phase-114 `_TooLarge` handling); `None` is a zero-cost guarded no-op (every pre-task caller — chat retrieval, `embed_one`, the summarizer, the importer — passes nothing and is byte-identical).
2. Do NOT touch `_batch_texts` / `_embed_batch` internals, `embed_batches` counting, or `embed_one`.
## Testing & Quality
- Unit: `tests/unit/` (extend the existing LLM-client test file — inspect `tests/unit/test_llm*.py` first):
- N texts sized to force ≥ 3 batches (shrink the token budget via settings or pick chunk lengths against `_REQUEST_TOKEN_BUDGET`) → `on_progress` called with `(1, N)`, `(k2, N)`, … `total` always `len(texts)`, first-indices strictly increasing, each = previous first + previous batch size (assert against `_batch_texts`'s actual partition — call it directly to get the expected boundaries);
- `on_progress=None` → the call sequence and return value are identical to pre-task (the existing suite is the pin);
- empty `texts` → no hook calls (the early return).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] the hook fires per top-level batch with the correct `(first, total)` pair (unit-pinned)
- [ ] every existing `embed`/`embed_one` call site is untouched and its tests green
- [ ] `uv run pytest tests/unit -q` green; no behavior change in completed work
@@ -0,0 +1,28 @@
# Task 02 — Importer plumbing: `on_chunk_progress` from `import_sources` to the content-chunk embed
**Phase:** `129_sync_chunk_progress` · **Source:** `TODO.md:5` — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (A6: batch-granularity chunk position)
## Objective
`import_sources` accepts an optional `on_chunk_progress` callback that fires with the CURRENT FILE's content-chunk position while that file's chunks embed — so the sync status can attribute the position to the file its per-file `progress` hook already named.
## Work
1. `app/rag/importer.py` — `import_sources` (L408): new keyword-only param `on_chunk_progress: Callable[[int, int], None] | None = None` (documented in the docstring next to the `progress` paragraph: same "only when provided — `None` callers pay nothing" rule; it carries the in-flight FILE's chunk position, not the file identity — the per-file `progress` hook owns that).
2. `app/rag/importer.py` — the processing loop's `_index_file` call: pass `on_chunk=on_chunk_progress` (a new keyword-only param on `_index_file`, default `None`).
3. `app/rag/importer.py` — `_index_file`:
- the TEXT branch → `_replace_content_chunks(session, doc=doc, llm=llm, content=content, rel=rel, on_chunk=on_chunk)`;
- the IMAGE branch (`_index_image_file`) and the PDF branch (`_index_pdf_file`) → pass `on_chunk` through to THEIR `_replace_content_chunks` calls (the image branch's one description-chunk embed reports `1/1` — it IS the file's only chunk; the PDF branch's content embed is the primary target). Verify each branch's embed path: if a branch embeds chunks somewhere other than `_replace_content_chunks`, route the hook there too (one hook per branch, content chunks only).
4. `app/rag/importer.py` — `_replace_content_chunks` (L603): new keyword-only param `on_chunk: Callable[[int, int], None] | None = None`, forwarded to `llm.embed([c.content for c in doc.chunks], on_progress=on_chunk)`. The token-cap re-chunk retry loop (the `while True` that halves `target`) re-embeds with the new chunk list — the hook reports against the NEW total automatically (no special case; document it in the docstring). The `doc.chunks = []` empty-content `break` never embeds (no hook fire).
5. `_store_summary` (and its summary-chunk `embed_one`/`embed` call) NEVER receives the hook — the counter is content chunks only (A6: no `1/1` noise after the real progress ends). The `scripts/import_docs.py` CLI passes nothing — byte-identical (verify the call site).
## Testing & Quality
- Unit: `tests/unit/test_importer.py` (extend) —
- a text file that chunks into ≥ 2 batches (size the fixture against the token budget, or monkeypatch `_batch_texts`) with a fake `on_chunk_progress` recorder → the recorder sees `(first, total)` pairs with `total == the file's chunk count`, one batch-run per file, and a SECOND file in the same run gets its OWN sequence (the position restarts per file — the recorder's `(first, total)` pairs never mix the two files' totals);
- the image branch (images toggle on, a fixture image) → the recorder sees exactly `(1, 1)` for that file;
- `on_chunk_progress=None` (the CLI shape) → no recorder, behavior byte-identical (the existing suite is the pin);
- the summary-chunk embed never fires the hook (the recorder's call count == the content-embed batches only).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] a multi-batch file's embed position is fully observable through `import_sources(on_chunk_progress=…)` (unit-pinned)
- [ ] the CLI path and the summary-chunk embed are untouched (byte-identical, verified by the existing suite)
- [ ] `uv run pytest tests/unit -q` green; no behavior change in completed work
@@ -0,0 +1,43 @@
# Task 03 — `SyncStatus` chunk fields: `current_chunk` / `chunks_total` on the status endpoint
**Phase:** `129_sync_chunk_progress` · **Source:** `TODO.md:5` — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (A6: batch-granularity chunk position)
## Objective
`GET /api/sync/status` carries the in-flight file's embed-chunk position (`current_chunk`, `chunks_total`) so the UI can render `· chunk n/m` — reset per run, cleared when the import phase ends, 0/0 idle (the phase-64/98 field conventions).
## Work
1. `app/api/sync.py` — `SyncStatus` dataclass: add
```python
# Phase 129: the current file's EMBED chunk position — the first
# chunk of the in-flight token-budgeted batch and the file's total
# content chunks (the LLMClient.embed hook, batch granularity).
# 0/0 before the first batch, during the prelude/overview/summaries
# phases, and in terminal states.
current_chunk: int = 0
chunks_total: int = 0
```
Update the `SyncStatus` docstring (the phase-64/98 progress-field paragraph) to name the new fields and their clearing contract.
2. `app/api/sync.py` — `_run_sync`:
- run start: `_status.current_chunk = 0`, `_status.chunks_total = 0` (with the other progress-field resets);
- the closure (the file's `_hook` convention — closures capture the module `_status`):
```python
def _chunk_hook(chunk: int, total: int) -> None:
_status.current_chunk = chunk
_status.chunks_total = total
```
passed to `import_sources(..., on_chunk_progress=_chunk_hook)` (alongside the existing `progress=_hook`);
- CLEARING (0/0) at every point the import phase ends — the `_status.phase = "overview"` assignment, the `_status.phase = "summaries"` assignment, and BOTH terminal states (the success and except blocks where `_status.current_file = None` — the phase-64 keep-final-FILE-counts convention does NOT extend to the sub-file chunk position: the file counts survive the terminal for the result line's context, the chunk position is a sub-position of a file that's done — it clears, like `current_file`);
- the unchanged-walk / no-sync-idle paths never set them (defaults 0/0).
3. `app/api/sync.py` — `sync_status` endpoint: add `"current_chunk": _status.current_chunk, "chunks_total": _status.chunks_total` to the returned dict (place them after the `files_*` fields; the dataclass defaults make idle 0/0 with no special-casing). Update the endpoint docstring's field list (the phase-98 paragraph) and the module docstring's status paragraph with the chunk-position contract.
## Testing & Quality
- Unit/integration: extend the sync unit suite (the file that fakes `import_sources` — inspect `tests/unit/test_sync_button.py` and the sync-pipeline test first):
- idle → `current_chunk == 0`, `chunks_total == 0` in the JSON (the dataclass defaults — the endpoint shape test);
- a running sync whose fake `import_sources` invokes `on_chunk_progress(1, 38)` then `(13, 38)` → the status between ticks reports the pair (assert on `_status` directly or via the endpoint through the existing app fixture);
- phase transition to `overview`/`summaries` → the fields are 0/0 again; terminal success/failed → 0/0 while `files_done`/`files_total` keep their final values (the convention split, pinned).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] the status endpoint JSON carries both fields; idle is 0/0; a running import reports the hook's pair
- [ ] the fields clear at phase transitions + terminal states while the file counts keep-final (pinned test)
- [ ] `uv run pytest tests/unit -q` green; no behavior change in completed work
@@ -0,0 +1,25 @@
# Task 04 — The label's chunk part: `Syncing… <file> (2/3) · chunk 14/38`
**Phase:** `129_sync_chunk_progress` · **Source:** `TODO.md:5` — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (A6: batch-granularity chunk position)
## Objective
The running sync label shows the chunk position as a third pinned part — rendered ONLY while `chunks_total > 0` (the import phase, after the file's first batch), hidden in every other state — and the untruncated full label (main + count + chunk part) keeps riding the button `title` + `#sync-result` (A4).
## Work
1. `frontend/index.html` — `#sync-label` (the phase-128 two-part markup): add the third part after the count span: `<span class="sync-label-chunk" hidden></span>`.
2. `frontend/assets/styles.css` — `.sync-label-chunk { flex: 0 0 auto; white-space: nowrap; font-family: var(--mono); }` (the same pinned-part language as `.sync-label-count` — place it right after that rule; both mono parts read as data).
3. `frontend/assets/sources.js` —
- get the chunk part at `mount` scope (the phase-128 `querySelector` pattern, null-guarded);
- `setSyncLabel(mainText, countText = "", chunkText = "")` — the new third arg: when `chunkText`, set `.sync-label-chunk.textContent` + `hidden = false`, else `""` + `hidden = true`; the UNTRUNCATED concatenation for `title` + `#sync-result` becomes `mainText` + (count? `" " + countText`) + (chunk? `" " + chunkText`) (A4 — the announcer hears the whole `Syncing… <file> (2/3) · chunk 14/38`);
- the import fall-through (the `fmtSyncPhaseLabel` fall-through / the 202/409 click label site — everywhere the import label is emitted with a status): `chunkText = status.chunks_total > 0 ? `· chunk ${status.current_chunk}/${status.chunks_total}` : ""` — every OTHER state (prelude, overview, summaries, upload, idle, settle) passes no chunk text (the part hides): the summaries phase's own `(n/m)` count is its position — no chunk part there;
- the `title`/`#sync-result` set on EVERY running label (the house rule) automatically carries the chunk part via the concatenation.
4. `tests/unit/test_frontend_sync_upload.py` — extend the phase-128 part pins: the import state with `chunks_total > 0` emits the chunk part (`· chunk 14/38` for the fixture values) and the untruncated `title` string is `Syncing… <file> (2/3) · chunk 14/38`; the import state with `chunks_total == 0` (before the first batch) hides the part; every other state's pins stay (the part hidden).
## Testing & Quality
- Unit: the pins above green; textContent-only (no HTML strings in the label path).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `· chunk n/m` renders only while `chunks_total > 0`; hidden in all other states (unit-pinned)
- [ ] the untruncated full label (with the chunk part) rides `title` + `#sync-result` (A4, unit-pinned)
- [ ] `uv run pytest tests/unit -q` green; no behavior change in completed phases (phase-128 pins still pass)
@@ -0,0 +1,24 @@
# Task 05 — E2E: the chunk position climbs through a large file's embed batches, endpoint + label
**Phase:** `129_sync_chunk_progress` · **Source:** `TODO.md:5` — "The `Syncing… name/of/file/here (2/3)` needs to show what chunk we're on out of how many" (A6: batch-granularity chunk position)
## Objective
A dedicated, mock-only Playwright suite (A16 — one per phase, run in isolation) proving the feature at both surfaces: the endpoint's `current_chunk` climbs 1 → N with `chunks_total` = the file's true chunk count, and the rendered label shows `· chunk n/m` while the file's embed span runs (then the part clears).
## Work
1. `tests/e2e/test_sync_chunk_progress.py` (new) — house pattern from `tests/e2e/test_sync_summary_visibility.py` (module app fixtures over the shared E2E Postgres, host temp-dir `kind=local` source, autouse truncate cleanup, mock-only, the `tests/e2e/slow_llm.py` proxy).
Sizing (the phase-98 rationale, re-sized for batches): the fixture is ONE local source `chunkprog/` holding ONE large markdown file (`big.md`, ~60 KB, ~30+ chunks at the default target — compute the expected chunk count deterministically in the test by calling `chunk_document` with the app's settings, the same function the importer uses — and assert `chunks_total` equals it) plus ONE tiny second file (the file counter then reads `(1/2)` → `(2/2)`, proving the chunk position restarts per file). `SLOW_DELAY_S` sized so each embed batch outlives the recorder's ~100 ms cadence (the proxy delays EVERY LLM call — embeds included — so the import span is densely sampled).
2. Test → observable mapping (Playwright Mapping Rule):
- `test_endpoint_reports_the_chunk_position` (the slow leg — tight-poll recorder on `GET /api/sync/status`, the phase-64 recorder pattern): while `phase == "import"`: some ticks have `chunks_total == <expected chunk count>` and `current_chunk` strictly climbing from 1 toward `chunks_total` (batch granularity — the sequence's LAST tick before the file ends is `chunks_total` or its final batch's first index; assert the climb + the total, not a contiguous run); while the second (tiny) file is in flight the position restarts (a later tick has `current_chunk == 1` with `chunks_total == 1`); the terminal state has `current_chunk == 0` and `chunks_total == 0` while `files_done == files_total == 2` (the keep-final-file-counts split, the phase-129 convention).
- `test_label_shows_the_chunk_part` (the slow leg — UI, the page's own 2 s poll): at the import span the rendered `#sync-label` parts: `.sync-label-main` matches `Syncing… chunkprog/big.md`, `.sync-label-count` matches `(1/2)`, and at some tick `.sync-label-chunk` is visible matching `· chunk \d+/\d+` with the total == the expected chunk count; the button `title` carries the UNTRUNCATED concatenation (A4 — assert it contains `· chunk` on the same tick); after the file's embed span the chunk part is hidden (`.sync-label-chunk.hidden` true) while the file label continues.
3. Header docstring: the run-in-isolation command (`uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov`), the mock-only gate, the sizing rationale (why the file is ~60 KB, why the slow proxy's delay doubles as the batch sampler), the fixture layout, the test→observable table.
## Testing & Quality
- E2E: this file IS the task's suite — run in isolation, green.
- Coverage: the phase's >90% `app/` bar is carried by tasks 01–03; this task adds the in-isolation Playwright gate.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` green in isolation (DB up: `podman compose up -d db`)
- [ ] the endpoint's `chunks_total` equals the deterministic `chunk_document` count for the fixture (the total is the file's TRUE chunk count, not a batch count)
- [ ] the chunk part renders only during the import phase and clears at the phase transition/terminal (UI-pinned)
- [ ] no behavior change in completed phases (the phase-64/98 label contract + the keep-final-counts convention hold)