phase: 98_sync_summary_visibility
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification complete. Final report:

**Phase 98 — Sync summary visibility: final verification pass** (all 5 tasks already complete; implementation verified against the design, no defects found, no code changes needed)

- **Implementation checked:** `SyncStatus` phase machine (4 new keys, terminal-keep counts), `on_progress` hook in `generate_folder_summaries`, `summary_pending` on `KbTreeSource`/`KbTreeFolder` + D3 rule in `build_kb_tree`, phase-aware sync labels + pending UI in `sources.js`, `.kb-summary-pending` CSS — all match decisions D1–D5.
- **Unit + integration:** `uv run pytest` → 2184 tests, 0 failed/errors (exit 0)
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (criterion >90% ✓; `app/api/sync.py` and `app/rag/folder_summaries.py` at 100%)
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- **Phase E2E (isolation):** `uv run pytest tests/e2e/test_sync_summary_visibility.py -v --no-cov` → **3 passed** (phase machine, live label, pending markers + gap-fill self-heal)
- **Regression suites (each isolated, `--no-cov`):** test_kb_tree ✓, test_ls_tree_drilldown 3 ✓, test_sync_button 3 ✓, test_sync_upload_progress 4 ✓, test_oneshot_llm_retry 2 ✓, test_local_directory_sources 3 ✓
- **Completion criteria:** all 7 verified green — status phase fields + terminal semantics; `Writing KB overview…`/`Summarizing folders… (n/m)` labels (title + aria-live); pending set == `missing_folder_summaries` (integration cross-check pinned at `test_docs_api.py:428`); CLI/`ls` byte-identity (no changes to those paths, pins green); suite/coverage/lint gates; dedicated + regression E2E. Commit left to the harness per protocol (no `git add`/`commit` run).
- **Decisions/deviations:** none — no fixes were required this pass.
- **Next pending phase:** `99_kb_tree_table_and_back_nav`.
This commit is contained in:
2026-09-13 00:23:05 -04:00
parent 909c96c7bc
commit f665a83b1a
39 changed files with 3265 additions and 112 deletions
+148 -23
View File
@@ -34,6 +34,18 @@ settled result line is
off the status's {"message": "uploaded"} detail; the nameless variant
after a reload / on the 409 re-attach), and the success announce
names the next step.
Phase 98 (task 02) pins the SYNC job's phase-aware label (D2):
fmtSyncPhaseLabel — "overview" → exactly "Writing KB overview…",
"summaries" → "Summarizing folders… <current_summary> (n/m)" (the
folder part omitted while current_summary is null → the bare
"Summarizing folders… (n/m)"), anything else → the byte-identical
phase-64 fmtSyncLabel("sync", …) fall-through — plus the status-object
threading (the tick's branch 1 + the initSyncButton running re-attach
pass the WHOLE status; the upload job and the 202/409 click stay on
the bare label — the upload status has no phase) and the A4 title-rule
adjustment (the full untruncated label rides the button title for
EVERY running label — the file-only rule is gone).
"""
from __future__ import annotations
@@ -182,30 +194,86 @@ def test_fmt_sync_label_signature_and_prefixes() -> None:
# ---------- enterSyncRunningState: full path to title + announcer ----------
def test_enter_running_state_writes_full_path_to_title_and_announcer() -> None:
def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
(phase 98 task 02, D2): "overview" → exactly "Writing KB
overview…" (no folder, no counts); "summaries" →
"Summarizing folders… <current_summary> (n/m)" — the folder part
(one leading space + current_summary) is omitted while
current_summary is null (the phase's first poll → the bare
"Summarizing folders… (n/m)"); anything else (the null prelude,
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
(same builder, same fields, same order). The UPLOAD job never
enters here — its status has no phase (D2)."""
body = _fn(_js(), "fmtSyncPhaseLabel")
assert "function fmtSyncPhaseLabel(status)" in body
# "overview": the exact copy, no folder, no counts.
i_ov = body.find('status.phase === "overview"')
i_sum = body.find('status.phase === "summaries"')
assert -1 < i_ov < i_sum
ov = body[i_ov:i_sum]
assert 'return "Writing KB overview…";' in ov
assert "current_summary" not in ov and "summaries_done" not in ov, (
"the overview label carries no folder and no counts"
)
# "summaries": the exact template — optional folder, counts always.
i_fall = body.find("return fmtSyncLabel")
assert i_sum < i_fall, "the summaries branch precedes the fall-through"
sum_ = body[i_sum:i_fall]
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
"the folder part — one leading space + the folder, or nothing"
)
assert (
"return `Summarizing folders…${folder} "
"(${status.summaries_done}/${status.summaries_total})`;"
) in sum_, "the exact D2 copy (with and without the folder)"
# anything else (null prelude, "import"): byte-identical to today's
# sync label — same builder, same fields, same order.
assert (
'return fmtSyncLabel("sync", status.current_file, '
"status.files_done, status.files_total);"
) in body[i_fall:]
def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
"""The running-state entry keeps the §7.4 never-stale mechanics
(disabled, aria-busy, spinning icon, the stale .is-error removed)
and — phase 64 — writes the FULL untruncated current file to the
button title (removed when null: no file yet) and the full
untruncated fmtSyncLabel text to BOTH the label span and
#sync-result (the aria-live announcer reads the full live path;
CSS only ellipsizes the button's span)."""
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
a sync-kind call carrying the status object takes the
fmtSyncPhaseLabel form, while the upload job (no status — its
endpoint has no phase) and the 202/409 click keep the bare
fmtSyncLabel label, byte-identical. The FULL untruncated label —
whatever its form — rides the button title (set for EVERY running
label: the phase-64 "title only when there is a current file"
rule is adjusted away) and #sync-result (the aria-live announcer);
CSS ellipsizes only the label span."""
body = _fn(_js(), "enterSyncRunningState")
assert (
"function enterSyncRunningState(kind, currentFile, done, total, status)"
in body
)
assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncBtn.title = currentFile" in body, "the full path on hover"
assert 'syncBtn.removeAttribute("title")' in body, "removed when no file yet"
i_set = body.find("if (currentFile) syncBtn.title = currentFile")
i_remove = body.find('syncBtn.removeAttribute("title")')
assert -1 < i_set < i_remove, "title is set (not removed) only when a file exists"
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
i_label = body.find("fmtSyncLabel(kind, currentFile, done, total)")
# the phase-aware split: sync + status → the D2 builder; the
# fall-through (upload / the click, no status) is the byte-
# identical phase-64 builder.
i_phase = body.find("fmtSyncPhaseLabel(status)")
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
assert 'kind === "sync" && status' in body
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
# A4: the untruncated label rides the title for EVERY running label
# (the remove-when-no-file rule is gone) and #sync-result.
assert "syncBtn.title = label" in body, "the full label on hover, always"
assert 'syncBtn.removeAttribute("title")' not in body, (
"the title is set for every running label (D2: not just file labels)"
)
i_title = body.find("syncBtn.title = label")
i_span = body.find("syncLabel.textContent = label")
i_result = body.find("syncResult.textContent = label")
assert -1 < i_label < i_span < i_result, (
"one label: built once, written to the span AND the announcer"
assert -1 < i_title < i_span < i_result, (
"one label: built once, written to title, span AND the announcer"
)
@@ -327,14 +395,16 @@ def test_click_branch_enters_sync_running_without_a_file() -> None:
def test_reattach_adopts_a_running_upload_only() -> None:
"""initSyncButton: the sync branches are unchanged (running
re-enters with the live file; the terminals render the last
result). With the sync IDLE it fetches the upload status: a RUNNING
upload run re-attaches (running state, upload kind — phase 90: no
live file, the label stays bare "Importing…" — the synthetic
running frame, the poll starts); a terminal upload is a no-op —
the fall-through is the plain idle settle (the boot-time loadTree()
already shows the current catalog)."""
"""initSyncButton: the sync terminals render the last result; the
RUNNING sync re-enters with the live file AND the whole status
(phase 98 task 02: the label is phase-aware — a mid-summaries
reload re-enters with the summaries label). With the sync IDLE it
fetches the upload status: a RUNNING upload run re-attaches
(running state, upload kind — phase 90: no live file, the label
stays bare "Importing…" — the synthetic running frame, the poll
starts); a terminal upload is a no-op — the fall-through is the
plain idle settle (the boot-time loadTree() already shows the
current catalog)."""
body = _fn(_js(), "initSyncButton")
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
assert 'fetch("/api/git-sources/upload/status")' in body
@@ -356,6 +426,61 @@ def test_reattach_adopts_a_running_upload_only() -> None:
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
def test_tick_sync_running_passes_the_status_for_the_phase_label() -> None:
"""Branch 1 of the two-job tick (phase 98 task 02): the sync
running state passes the WHOLE syncStatus object as the fifth
argument — the phase-aware label (overview / summaries) reads its
phase fields. The upload running branch stays on the BARE four-arg
label (its status has no phase — D2): no fifth argument, no status
object anywhere in the branch."""
tick = _tick(_js())
b_sync_run = tick.find('syncStatus.state === "running"')
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
b_sync_ok = tick.find('syncStatus.state === "success"')
assert -1 < b_sync_run < b_up_run < b_sync_ok
branch1 = tick[b_sync_run:b_up_run]
i_call = branch1.find("enterSyncRunningState(")
call = branch1[i_call:branch1.find(");", i_call)]
assert re.search(
r'"sync",\s*syncStatus\.current_file,\s*syncStatus\.files_done,\s*'
r"syncStatus\.files_total,\s*syncStatus\s*$",
call,
), "branch 1 passes the whole status (fifth arg) for the phase-aware label"
branch2 = tick[b_up_run:b_sync_ok]
i_call2 = branch2.find("enterSyncRunningState(")
call2 = branch2[i_call2:branch2.find(");", i_call2)]
assert re.fullmatch(
r'enterSyncRunningState\(\s*"upload",\s*uploadStatus\.current_file,\s*'
r"uploadStatus\.files_done,\s*uploadStatus\.files_total\s*$",
call2,
), "the upload job keeps the bare four-arg label — no status, no phase (D2)"
assert "phase" not in call2 and "summaries" not in call2
def test_reattach_sync_running_passes_the_status_for_the_phase_label() -> None:
"""initSyncButton (phase 98 task 02): the RUNNING sync re-attach
passes the WHOLE status object — a reload mid-summaries re-enters
the running state with the summaries label (the never-stale
contract); the RUNNING upload re-attach stays on the bare four-arg
label (its status has no phase — D2)."""
body = _fn(_js(), "initSyncButton")
i_sync = body.find('"sync", status.current_file')
assert i_sync != -1
call = body[i_sync:body.find(");", i_sync)]
assert re.search(
r'"sync",\s*status\.current_file,\s*status\.files_done,\s*'
r"status\.files_total,\s*status\s*$",
call,
), "the RUNNING sync re-attach passes the status object (fifth arg)"
i_up = body.find('"upload", upload.current_file')
assert i_up != -1
call2 = body[i_up:body.find(");", i_up)]
assert re.fullmatch(
r'"upload",\s*upload\.current_file,\s*upload\.files_done,\s*upload\.files_total\s*$',
call2,
), "the RUNNING upload re-attach keeps the bare four-arg label (D2)"
# ---------- the section header + the page comment ----------