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
+93 -49
View File
@@ -364,7 +364,8 @@
*
* Phase 149 (task 01) — the sync detail box + the icon-only running
* button: the owner's report was that the full live label (a long
* source/relative/path + the counts + the chunk part) is clipped in
* source/relative/path + the counts + the chunk part + the phase-152
* image part) is clipped in
* the pill even on desktop — the mobile solution (stuffing the parts
* in the button) had become the permanent surface. The fix: while a
* sync/upload run is in flight, the button shows ONLY the spinning
@@ -399,7 +400,8 @@
* global [hidden] rule does the display). Two content modes, decided
* at the callsites (the phase-149 "the callsites are the only
* decision sites" rule):
* • running mode — setSyncLabel(main, count, chunk, running) writes
* • running mode — setSyncLabel(main, count, chunk, running, image)
* writes
* the SAME untruncated full label through setSyncProgress, ONLY
* while running (the idle / settle button labels — "Sync sources",
* "Synced HH:MM" — must not fill the panel; they stay on the
@@ -749,7 +751,12 @@ export async function mount(root) {
* pinned "(n/m)" position (hidden when absent); phase 129 (task
* 04): + .sync-label-chunk, the pinned "· chunk n/m" embed-batch
* position of the in-flight file (hidden in every state but the
* import phase after the file's first batch)), #sync-cancel-btn
* import phase after the file's first batch); phase 152 (task 03):
* the in-flight PDF's "· image n/m" vision-image position gets NO
* span of its own — the running button is the spinner (its parts
* are hidden, phase 149), so the position rides only the composed
* full string (the box / title / running aria-label)),
* #sync-cancel-btn
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
* only while a sync run is in flight), #sync-icon (the
* spinner icon), #sync-progress (Phase 149 task 01 + Phase 150:
@@ -811,29 +818,38 @@ export async function mount(root) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* Phase 128 (task 01) + Phase 129 (task 04): the label PARTS
* writer — the SINGLE point that writes #sync-label. mainText lands
* in the MAIN part (.sync-label-main — the shrinks-to-fit prefix +
* file/folder, the part CSS ellipsizes); countText lands in the
* COUNT part (.sync-label-count — the pinned "(n/m)" position,
* NEVER clipped); chunkText (phase 129) lands in the CHUNK part
* (.sync-label-chunk — the pinned "· chunk n/m" embed-batch
* position of the in-flight file). The COUNT and CHUNK parts share
* one contract: present → its textContent + hidden=false, absent →
* emptied + hidden=true (each span is hidden whenever there is
* nothing of its kind to show). Then the UNTRUNCATED full label —
* mainText + (countText ? " " + countText : "") + (chunkText ?
* " " + chunkText : "") — rides the button title (A4 — the screen
* reader hears the whole "Syncing… <file> (2/3) · chunk 14/38"
* there, and in the #sync-progress box — the sole aria-live
* region, phase 150 — while a run is in flight); the visible
* spans carry main + count + chunk, the spaces between them are
* the CSS gap. textContent only — the house rule, no HTML strings.
* Every label state goes through here (the running builders, the
* idle / settle labels, the 202/409 click, the re-attach).
/* Phase 128 (task 01) + Phase 129 (task 04) + Phase 152 (task 03):
* the label PARTS writer — the SINGLE point that writes #sync-label.
* mainText lands in the MAIN part (.sync-label-main — the
* shrinks-to-fit prefix + file/folder, the part CSS ellipsizes);
* countText lands in the COUNT part (.sync-label-count — the pinned
* "(n/m)" position, NEVER clipped); chunkText (phase 129) lands in
* the CHUNK part (.sync-label-chunk — the pinned "· chunk n/m"
* embed-batch position of the in-flight file); imageText (phase
* 152) is the in-flight PDF's "· image n/m" vision-image position —
* it gets NO span of its own (the running button is the spinner —
* its parts are hidden, phase 149), it rides ONLY the composed full
* string. The COUNT and CHUNK parts share one contract: present →
* its textContent + hidden=false, absent → emptied + hidden=true
* (each span is hidden whenever there is nothing of its kind to
* show). Then the UNTRUNCATED full label — mainText + (countText ?
* " " + countText : "") + (chunkText ? " " + chunkText : "") +
* (imageText ? " " + imageText : "") — rides the button title (A4 —
* the screen reader hears the whole "Syncing… <file> (2/3) · chunk
* 14/38 · image 3/15" there, and in the #sync-progress box — the
* sole aria-live region, phase 150 — while a run is in flight); the
* visible spans carry main + count + chunk, the spaces between them
* are the CSS gap. textContent only — the house rule, no HTML
* strings. Every label state goes through here (the running
* builders, the idle / settle labels, the 202/409 click, the
* re-attach).
* Phase 149 (task 01): the fourth parameter — `running` (the
* in-flight flag; the callsites are the ONLY decision sites: the
* running entry passes true, the settle passes the default false).
* Phase 152 (task 03): the fifth parameter — `imageText` (the "·
* image n/m" part; the chunk and image parts are distinct and both
* may show, and each disappears independently — the bare builders
* and the non-running calls pass nothing, so both default "").
* Phase 150 (task 02): while running, the SAME untruncated `full`
* string goes into the box through the single box writer
* (setSyncProgress — textContent only), so the box can never
@@ -846,7 +862,7 @@ export async function mount(root) {
* non-running call restores the ship value "Sync sources" (the
* static HTML attribute is the idle truth — the JS only
* overwrites/restores it, never removes it). */
function setSyncLabel(mainText, countText = "", chunkText = "", running = false) {
function setSyncLabel(mainText, countText = "", chunkText = "", running = false, imageText = "") {
if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) {
if (countText) {
@@ -866,10 +882,16 @@ export async function mount(root) {
syncLabelChunk.hidden = true;
}
}
// Phase 152 (task 03): the image part is the LAST piece of the
// composed full string (after the chunk part) — the box, the
// button title, and the running aria-label all carry it; the
// button's label spans stay as-is (no new span — see the
// docstring).
const full =
mainText +
(countText ? ` ${countText}` : "") +
(chunkText ? ` ${chunkText}` : "");
(chunkText ? ` ${chunkText}` : "") +
(imageText ? ` ${imageText}` : "");
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
// Phase 150 (task 02): the box — the sole sync text surface and
// the sole aria-live region — receives the running label through
@@ -924,6 +946,8 @@ export async function mount(root) {
* 04): this builder still returns NO chunk part — the pinned
* "· chunk n/m" part is added by fmtSyncPhaseLabel's import
* fall-through (the only site that sees the status's chunk
* fields), and phase 152 (task 03) likewise adds the "· image
* n/m" part there (the only site that sees the status's image
* fields), so the upload label and the 202/409 click stay bare. */
function fmtSyncLabel(kind, currentFile, done, total) {
const prefix = kind === "upload" ? "Importing" : "Syncing…";
@@ -932,30 +956,38 @@ export async function mount(root) {
return { main, count };
}
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework):
* the SYNC job's phase-aware running label — the post-import span
* where the file count sits still (the owner's "the number pauses
* for a really long time"): "overview" → "Writing KB overview…"
* (no count part); "summaries" → main "Summarizing folders…
* <source/folder>" + count "(n/m)" — the folder part is omitted
* while current_summary is null (the phase's first poll) and the
* COUNT IS ALWAYS EMITTED in this phase (the hook always carries a
* total) and the CHUNK part stays empty (the summaries phase's own
* (n/m) is its position — the status's chunk fields are cleared at
* this transition); anything else (the null prelude, "import") →
* the phase-64 fmtSyncLabel parts, byte-identical (same builder,
* same fields, same order) + the phase-129 CHUNK part: "· chunk
* n/m" ONLY while chunks_total > 0 (a file's embed batch is in
* flight — n the 1-based first chunk of the batch, m the file's
* total content chunks, batch granularity), empty before the first
* batch (0/0) so the part hides. The UPLOAD job never passes a
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework)
* + Phase 152 (task 03): the SYNC job's phase-aware running label —
* the post-import span where the file count sits still (the owner's
* "the number pauses for a really long time"): "overview" →
* "Writing KB overview…" (no count part); "summaries" → main
* "Summarizing folders… <source/folder>" + count "(n/m)" — the
* folder part is omitted while current_summary is null (the phase's
* first poll) and the COUNT IS ALWAYS EMITTED in this phase (the
* hook always carries a total) and the CHUNK and IMAGE parts stay
* empty (the summaries phase's own (n/m) is its position — the
* status's chunk + image fields are cleared at this transition);
* anything else (the null prelude, "import") → the phase-64
* fmtSyncLabel parts, byte-identical (same builder, same fields,
* same order) + the phase-129 CHUNK part: "· chunk n/m" ONLY while
* chunks_total > 0 (a file's embed batch is in flight — n the
* 1-based first chunk of the batch, m the file's total content
* chunks, batch granularity), empty before the first batch (0/0) so
* the part hides + the phase-152 IMAGE part: "· image n/m" ONLY
* while images_total > 0 (a PDF's vision span is in flight — n the
* 1-based image about to be described, m the file's total images —
* ONE continuous counter across the PDF's three vision spans, the
* importer owns the continuity), empty before the first image
* (0/0) so the part hides; the two positions are distinct and both
* may show ("… · chunk 125/126 · image 3/15"), each disappearing
* independently. The UPLOAD job never passes a
* status (its endpoint has no phase — D2) and keeps the bare
* fmtSyncLabel parts. The untruncated full label rides the button
* title + #sync-progress like every running label (A4 —
* setSyncLabel). */
function fmtSyncPhaseLabel(status) {
if (status.phase === "overview") {
return { main: "Writing KB overview…", count: "", chunk: "" };
return { main: "Writing KB overview…", count: "", chunk: "", image: "" };
}
if (status.phase === "summaries") {
const folder = status.current_summary ? ` ${status.current_summary}` : "";
@@ -963,11 +995,14 @@ export async function mount(root) {
main: `Summarizing folders…${folder}`,
count: `(${status.summaries_done}/${status.summaries_total})`,
chunk: "", // this phase's own (n/m) is its position — no chunk part
image: "", // the image position belongs to the import phase only
};
}
// null phase (the prelude) or "import" — today's byte-identical
// sync label (the phase-64 contract, unchanged) + the phase-129
// chunk part (hidden before the in-flight file's first batch).
// chunk part (hidden before the in-flight file's first batch) +
// the phase-152 image part (hidden before the in-flight PDF's
// first vision describe call).
const { main, count } = fmtSyncLabel(
"sync", status.current_file, status.files_done, status.files_total
);
@@ -975,7 +1010,11 @@ export async function mount(root) {
status.chunks_total > 0
? `· chunk ${status.current_chunk}/${status.chunks_total}`
: "";
return { main, count, chunk };
const image =
status.images_total > 0
? `· image ${status.images_done}/${status.images_total}`
: "";
return { main, count, chunk, image };
}
function fmtSyncResult(detail) {
@@ -1011,7 +1050,10 @@ export async function mount(root) {
* Phase 129 (task 04): the third, pinned CHUNK part ("· chunk n/m"
* — the in-flight file's embed-batch position) is destructured the
* same way (absent from the bare builders — the upload job and the
* 202/409 click — so the "" default keeps the part hidden there).
* 202/409 click — so the "" default keeps the part hidden there);
* phase 152 (task 03) destructures the fourth, IMAGE part ("·
* image n/m" — the in-flight PDF's vision-image position) the same
* way (it too rides only the composed full string — no span).
* Same never-stale mechanics as before (disabled, aria-busy,
* spinning icon, no is-error). Phase 149 (task 01): the writer call
* passes `running = true` — the detail box reveals with the full
@@ -1025,13 +1067,15 @@ export async function mount(root) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
const { main, count, chunk = "" } =
const { main, count, chunk = "", image = "" } =
kind === "sync" && status
? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total);
// the parts + the A4 untruncated title / announcer + the phase-149
// detail box (revealed, full label) + the running aria-label
setSyncLabel(main, count, chunk, true);
// detail box (revealed, full label — phase 152: with the image
// part when a PDF's vision span is in flight) + the running
// aria-label
setSyncLabel(main, count, chunk, true, image);
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
// Phase 149 (task 01): the icon-only running button — the class
+3 -2
View File
@@ -483,8 +483,9 @@
</p>
</div>
<!-- Phase 149 (task 01): the sync detail box — the full UNTRUNCATED live
label ("Syncing… <source/rel/path> (n/m) · chunk n/m") in a bordered
panel under the button row. Phase 150 (task 01) folded the
label ("Syncing… <source/rel/path> (n/m) · chunk n/m" — phase 152
appends "· image n/m" while a PDF's vision span is in flight)
in a bordered panel under the button row. Phase 150 (task 01) folded the
announcer in: the previous live-region span beside it is retired,
and THIS box is now the SOLE sync text surface AND the
SOLE aria-live region for the sync area