phase: 152_sync_progress_image_pass
Build and Push Containers / build-and-push-app (push) Successful in 3m34s
Build and Push Containers / build-and-push-db (push) Successful in 23s

All criteria verified. The CLI path is hook-less (only `app/api/sync.py` passes the hook), and the byte-identity is pinned by `test_on_image_progress_none_run_is_byte_identical`.

## Final Report — Phase 152 final verification pass

- **Verified (no new code needed):** `SyncStatus.images_done/images_total` + reset/clear rules in `app/api/sync.py`; `on_image_progress` threaded through the importer's three PDF vision spans with the continuous per-file counter; the `_image_hook` closure; `sources.js` `· image n/m` label part (box + title + aria).
- **C1** status climbs 1→N + UI shows `· image n/m`: ✅ unit 3/15→15/15, integration 1/15→7/15, E2E 1/4→4/4 (title/aria ride the full string)
- **C2** never frozen: ✅ E2E asserts ≥3 distinct positions, starting at 1, non-decreasing, ≤ total, reaching it
- **C3** cleared at overview/summaries + all terminals; per-file reset via `_hook`; no stale position for image-less files: ✅ unit pins + E2E settle (image part gone in box/title/aria, endpoint 0/0)
- **C4** `/api/sync/status` additive-only: ✅ full-dict pin passes (all pre-existing keys unchanged + the two new 0/0 keys)
- **C5** hook-less caller byte-identical: ✅ CLI passes no hook; `test_on_image_progress_none_run_is_byte_identical` green
- **C6** `uv run pytest`: **3526 passed, exit 0** (95s); dedicated E2E in isolation: **2 passed** (95s); `uv run ruff check . && uv run pyright`: **clean**; coverage: **99% on `app/` (>90% gate)**
- Regression: all five neighbouring E2E suites green in isolation (progress_box 8, chunk_progress 2, label_mobile 4, pdf_support 6, image_documents 4)
- **C7** atomic commit: left to the harness (no git index/history touched per rules)
- **Docs:** README.md — sync-label paragraph already updated this phase, accurately; all `.env.example` vars documented; routes/commands verified — **no changes needed**. AGENTS.md — all references (PLAN, user_stories, test_smoke.py, .env.example, gpg config, .gitignore) verified — **no changes needed**
- **Defects found in prior work:** none (one prior env-leak defect was already fixed inside this phase's task 04, conftest)
- No live-infrastructure touched; no ntfy used. **Next pending phase:** none — `todo/` held only this phase
This commit is contained in:
2026-10-03 01:52:41 -04:00
parent c7cfcd26b5
commit 12fa69b7ea
39 changed files with 3971 additions and 133 deletions
+352 -1
View File
@@ -93,6 +93,7 @@ import asyncio
import logging
import os
import subprocess
import textwrap
import threading
import time
from collections.abc import Callable, Iterator, Sequence
@@ -100,6 +101,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pymupdf as fitz
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
@@ -309,6 +311,12 @@ class FakeImportSources:
# regresses) — the canned fake never fires it (the runner's
# status chunk fields stay 0/0, like a no-batch import).
self.chunk_hooks: list[object] = []
# Phase 152 (task 03): the image-position hook the runner
# passes (a live closure while wired, None if the wiring
# regresses) — the canned fake never fires it (the status
# image fields stay 0/0, like the chunk fields of a no-batch
# import).
self.image_hooks: list[object] = []
async def __call__(
self,
@@ -320,6 +328,7 @@ class FakeImportSources:
session: Session | None = None,
progress: Callable[[str, str, int, int], None] | None = None,
on_chunk_progress: Callable[[int, int], None] | None = None, # phase 129
on_image_progress: Callable[[int, int], None] | None = None, # phase 152
ignore_by_root: dict[str, list[str]] | None = None,
include_hidden_by_root: dict[str, bool] | None = None,
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
@@ -329,6 +338,7 @@ class FakeImportSources:
self.prune_flags.append(prune)
self.progress_hooks.append(progress)
self.chunk_hooks.append(on_chunk_progress)
self.image_hooks.append(on_image_progress)
self.ignore_maps.append(ignore_by_root or {})
self.include_hidden_maps.append(include_hidden_by_root or {})
self.doc_dates_maps.append(doc_dates_by_root or {})
@@ -500,6 +510,10 @@ def test_admin_sync_success_reports_full_detail(
# (the dataclass defaults).
"current_chunk": 0,
"chunks_total": 0,
# Phase 152 (task 01): the image-position keys — 0/0 idle
# (the dataclass defaults).
"images_done": 0,
"images_total": 0,
# Phase 98 (task 01): the phase-machine keys — null/0/0/0 idle.
"phase": None,
"current_summary": None,
@@ -656,13 +670,23 @@ def test_double_trigger_while_running_returns_409(
class _GatedImport:
"""An import that fires the runner's progress hook once, then
parks on a threading gate — the test reads the status mid-import
(the phase ``"import"`` + the file hook)."""
(the phase ``"import"`` + the file hook).
Phase 152 (task 03): optionally ALSO fires the in-flight file's
image-position pairs (``image_pairs``) through the runner's OWN
``on_image_progress`` closure: pair one lands before the main park
(readable mid-run), every later pair after the main park + its own
park (``image_release``), so each position is readable on the
status between polls (the phase-129 chunk idiom, mirrored for the
vision-image position)."""
def __init__(
self,
summary: ImportSummary,
started: threading.Event,
release: threading.Event,
image_pairs: Sequence[tuple[int, int]] = (),
image_release: threading.Event | None = None,
) -> None:
self.summary = summary
self.started = started
@@ -672,6 +696,13 @@ class _GatedImport:
# passes (recorded for the wiring pin — the gated fake never
# fires it, so the status chunk fields stay 0/0).
self.chunk_hook: Callable[[int, int], None] | None = None
# Phase 152 (task 03): the image-position hook the runner
# passes (recorded for the wiring pin) + the optional image
# pairs (see the class docstring).
self.image_hook: Callable[[int, int], None] | None = None
self.image_pairs = list(image_pairs)
self.image_release = image_release
self.image_hook_calls: list[tuple[int, int]] = []
async def __call__(
self,
@@ -683,6 +714,7 @@ class _GatedImport:
session: Session | None = None,
progress: Callable[[str, str, int, int], None] | None = None,
on_chunk_progress: Callable[[int, int], None] | None = None, # phase 129
on_image_progress: Callable[[int, int], None] | None = None, # phase 152
ignore_by_root: dict[str, list[str]] | None = None,
include_hidden_by_root: dict[str, bool] | None = None,
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
@@ -691,6 +723,25 @@ class _GatedImport:
progress("repo", "notes/deep.md", 1, 3)
self.hook_calls.append(("repo", "notes/deep.md", 1, 3))
self.chunk_hook = on_chunk_progress
self.image_hook = on_image_progress
if on_image_progress is not None and self.image_pairs:
# Phase 152 (task 03): the in-flight PDF's image-position
# pairs, fired through the runner's OWN hook closure (the
# closure under test) — pair one before the main park, each
# later pair after the main park + its own park (each park
# lets the test read the status between polls). A wiring
# regression to None skips this branch and the pair test
# times out.
on_image_progress(*self.image_pairs[0])
self.image_hook_calls.append(self.image_pairs[0])
self.started.set()
await asyncio.to_thread(self.release.wait)
for pair in self.image_pairs[1:]:
on_image_progress(*pair)
self.image_hook_calls.append(pair)
if self.image_release is not None:
await asyncio.to_thread(self.image_release.wait)
return self.summary
self.started.set()
await asyncio.to_thread(self.release.wait)
return self.summary
@@ -867,6 +918,305 @@ def test_status_reports_the_phase_machine_across_the_run(
assert gated_import.hook_calls == [("repo", "notes/deep.md", 1, 3)]
def test_image_position_climbs_between_polls_mid_run(
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""Phase 152 (task 03): while a PDF's vision span is in flight,
the status carries the in-flight image position — the pairs are
fired through the runner's OWN ``_image_hook`` closure (task 03's
wiring: ``import_sources(..., on_image_progress=_image_hook)``),
and the position CLIMBS between two polls of
``GET /api/sync/status`` (1/15 → 7/15 — monotonic, never above
the total, no backwards jumps — the owner's stuck label now
moves), paired with the file the ``progress`` hook named (the
hooks are paired — the position belongs to that file); the
success terminal clears the position to 0/0 while the FILE
counts keep their final values (the convention split — same as
the chunk position)."""
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)
image_release = threading.Event()
gated_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
threading.Event(),
image_pairs=[(1, 15), (7, 15)], # two describe calls of a 15-image span
image_release=image_release,
)
monkeypatch.setattr(sync_api, "import_sources", gated_import)
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
_login(sync_client)
# Idle: the image keys ride along as 0/0 (the dataclass defaults).
body = sync_client.get("/api/sync/status").json()
assert body["state"] == "idle"
assert body["images_done"] == 0 and body["images_total"] == 0
assert sync_client.post("/api/sync").status_code == 202
try:
assert gated_import.started.wait(5.0), "the run never reached the first image pair"
# Poll 1 — the import phase, pair one live: the position is
# 1/15, paired with the file the progress hook named; the
# chunk position is 0/0 (this canned file has no embed
# batches — the two positions are independent).
s = sync_client.get("/api/sync/status").json()
assert s["state"] == "running"
assert s["phase"] == "import"
assert s["current_file"] == "repo/notes/deep.md"
assert s["files_done"] == 1 and s["files_total"] == 3
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
assert s["images_done"] == 1 and s["images_total"] == 15
# Release pair one: the import fires pair two (7/15 — the
# NEXT describe call of the same 15-image span) and parks
# again.
gated_import.release.set()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
s = sync_client.get("/api/sync/status").json()
if s["images_done"] == 7:
break
assert s["state"] == "running", (
f"unexpected state {s['state']!r} while waiting for pair two: {s}"
)
time.sleep(0.05)
else:
pytest.fail("the second image pair never landed on the status")
# Poll 2 — the position CLIMBED (1 → 7, monotonic — no
# backwards jump, never above the total): the label is
# moving, not frozen.
assert s["state"] == "running"
assert s["phase"] == "import"
assert s["current_file"] == "repo/notes/deep.md"
assert s["images_done"] == 7 and s["images_total"] == 15
assert 0 < s["images_done"] <= s["images_total"]
image_release.set()
finally:
gated_import.release.set()
image_release.set()
body = _poll(sync_client, "success")
# Terminal: the image position clears (0/0) ... the FILE counts
# KEEP their final values (the convention split — the position is
# a sub-position of a done file, like the chunk position).
assert body["images_done"] == 0 and body["images_total"] == 0
assert body["current_file"] is None
assert body["files_done"] == 1 and body["files_total"] == 3
# Wiring: BOTH pairs went through the runner's OWN live closure
# (the status reads above came through it — a wiring regression
# to None would have skipped the firing branch and timed out),
# and the file hook fired once.
assert gated_import.image_hook is not None
assert gated_import.image_hook_calls == [(1, 15), (7, 15)]
assert gated_import.hook_calls == [("repo", "notes/deep.md", 1, 3)]
# --- phase 152 (task 04): the full-run image-position monotonicity -------
def _kb_solid_png(value: int) -> bytes:
"""A 40x30 solid-color PNG (channel *value*): every value used
below stays under the phase-151 blank gate's 235 mean floor
(kept, never blank-skipped), and distinct values are distinct
images (the parser's dedupe keeps every one)."""
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 40, 30), 0)
pix.clear_with(value)
return pix.tobytes("png")
def _kb_image_pdf(path: Path, values: Sequence[int]) -> None:
"""A REAL one-page PDF (the fixture KB's image-bearing file): a
text block comfortably over the phase-140 100-char sparseness
bar (a USABLE layer — the phase-133 gate stays closed, zero
pictorial candidates even with the toggle on — the "no other
vision span" class) carrying *values* the solid-color embedded
images (one placement each, distinct)."""
doc = fitz.open()
page = doc.new_page()
text = (
"The quadlet unit file defines the container interface, the "
"image, the network, and the restart policy. Every key maps "
"to a docker flag in the generated service file, and comments "
"use the usual semicolon form throughout the whole document."
)
for i, line in enumerate(textwrap.wrap(text, 60)):
page.insert_text((72, 72 + i * 14), line)
y = 300
for value in values:
page.insert_image(fitz.Rect(72, y, 112, y + 30), stream=_kb_solid_png(value))
y += 40
doc.save(path)
doc.close()
class _GatedVisionLLM(FakeEmbedder):
"""The phase-152 (task 04) mock LLM — the deterministic
in-process ``FakeEmbedder`` whose VISION calls (the multimodal
describe shape ``describe_image`` sends) PARK on a threading
gate: the run parks inside the PDF's embedded-image span, so the
test can read the status between the describe calls
(deterministic — no sleeps, no wall-clock dependence; the gate
holds the run until the test releases it). The hook fired BEFORE
the parked call (the task-02 contract), so the position is live
while the run is parked. Text (``lite``) calls keep the
``FakeEmbedder`` behaviour; the embeds are the deterministic
vectors."""
def __init__(self) -> None:
super().__init__()
self.vision_parks = 0 # the vision calls that parked (and released)
self.vision_release = threading.Event()
async def chat(
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
# One slow vision describe call — the gate (clear before
# the wait — a latching set would otherwise leak across
# calls): the test releases after reading the position.
self.vision_release.clear()
self.vision_parks += 1
await asyncio.to_thread(self.vision_release.wait)
return "A solid color swatch described." # deterministic, non-empty
return await super().chat(messages, model)
def test_image_position_monotonic_across_polls_over_a_real_pdf(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
clean_documents: None,
) -> None:
"""Phase 152 (task 04): a FULL sync run over a fixture KB with
ONE image-bearing PDF (the REAL ``import_sources`` — the
runner's ``_image_hook`` is driven by the importer's own
embedded-image span; the LLM is the deterministic mock whose
vision calls park on a gate) — polling ``GET /api/sync/status``
from the test while the run is parked between the describe
calls, the observed ``images_done`` sequence is MONOTONICALLY
NON-DECREASING, never above ``images_total``, and ends at
``images_total`` (no backwards jumps, no overshoot — the
owner's stuck label climbs one describe call at a time); the
position is paired with the file the ``progress`` hook named
(the hooks are paired), the success terminal clears it to 0/0
while the FILE counts keep their final values, and the KB
carries the PDF + its N image docs (the span really ran)."""
kb = tmp_path / "PictKB"
kb.mkdir()
(kb / "notes.md").write_text("# Notes\nthe local fixture\n", encoding="utf-8")
_kb_image_pdf(kb / "report.pdf", (90, 130, 170)) # 3 distinct images
n = 3
_seed_local(db, kb)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(sources_dir=str(tmp_path / "bor")),
)
clone_calls, fake_clone = _fake_clone()
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
llm = _GatedVisionLLM()
llm.settings = Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
import_extensions="md,txt,pdf",
images=True,
image_dir=str(tmp_path / "images"),
pdf_page_dir=str(tmp_path / "pages"),
)
monkeypatch.setattr(sync_api, "LLMClient", lambda: llm)
_stub_probe(monkeypatch) # the probe passes on the mock (no network)
_login(sync_client)
def status() -> dict:
return sync_client.get("/api/sync/status").json()
def wait_for(predicate, what: str, timeout: float = 10.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.02)
raise AssertionError(f"timed out waiting for {what}")
# Idle: the image keys ride along as 0/0 (the dataclass
# defaults).
body = status()
assert body["state"] == "idle"
assert body["images_done"] == 0 and body["images_total"] == 0
assert sync_client.post("/api/sync").status_code == 202
try:
# The run reaches the PDF's vision span: the first hook fired
# (total = the span's N) and the first describe call parked
# the run (the gate).
wait_for(
lambda: status()["state"] == "running"
and status()["phase"] == "import"
and status()["images_total"] == n,
"the run to reach the PDF's vision span",
)
# Each describe call: the hook fired BEFORE the call, so the
# position is live while the run is parked in the call —
# observe it, release the call, let the next position climb.
observed: list[tuple[int, int]] = []
for expected in range(1, n + 1):
def position_ready(target: int = expected) -> bool:
# the default argument binds the loop variable (B023)
return status()["images_done"] == target
wait_for(position_ready, f"images_done to climb to {expected}")
s = status()
assert s["state"] == "running" and s["phase"] == "import"
# The position is paired with the file the progress hook
# named (the two hooks are paired — the position belongs
# to that file).
assert s["current_file"] == "PictKB/report.pdf"
observed.append((s["images_done"], s["images_total"]))
llm.vision_release.set()
# The run finishes the rest of the pipeline (the span is
# done — no more vision calls) and settles.
assert llm.vision_parks == n, "one park per describe call"
finally:
llm.vision_release.set()
body = _poll(sync_client, "success")
# The observed sequence: monotonically non-decreasing, never
# above the total, ending at the total (the gate made it exact —
# 1..N, no backwards jumps, no overshoot).
dones = [d for d, _ in observed]
assert dones == list(range(1, n + 1)), f"the sequence: {observed}"
assert all(d <= t for d, t in observed), "no overshoot"
assert dones[-1] == n, "ends at images_total"
assert {t for _, t in observed} == {n}, "one total for the whole span"
# Terminal: the image position clears (0/0) ... the FILE counts
# KEEP their final values (the convention split — the position
# is a sub-position of a done file, like the chunk position).
assert body["images_done"] == 0 and body["images_total"] == 0
assert body["current_file"] is None
assert body["files_done"] == 2 and body["files_total"] == 2
assert clone_calls == [] # nothing to clone — local is walked directly
# The KB carries the fixture md + the PDF + its N image docs —
# the span really ran (N new image documents, no failures).
assert body["detail"]["added"] == 2 + n
assert body["detail"]["errors"] == 0
docs = {(d["source"], d["path"])
for d in sync_client.get("/api/docs").json()["documents"]}
assert ("PictKB", "report.pdf") in docs
assert sum(1 for _, p in docs if "#page=" in p) == n
def test_failed_terminal_after_the_summary_hook_clears_phase_keeps_counts(
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
@@ -1497,6 +1847,7 @@ def test_import_error_is_reported_with_credentials_masked(
session: Session | None = None,
progress: Callable[[str, str, int, int], None] | None = None,
on_chunk_progress: Callable[[int, int], None] | None = None, # phase 129
on_image_progress: Callable[[int, int], None] | None = None, # phase 152
ignore_by_root: dict[str, list[str]] | None = None,
include_hidden_by_root: dict[str, bool] | None = None,
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106