phase: 134_sync_cancel
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 134 final verification pass — all green** (all 3 task files already in `complete/`; no implementation was missing)

- Verified: 5-state `SyncStatus` + `cancelled` terminal (error None, final counts kept), `POST /api/sync/cancel` (202/409/403), cooperative `_check_cancelled` boundaries, shutdown `CancelledError` contract preserved, `#sync-cancel-btn` show/hide + neutral "Sync cancelled" settle, 8-branch poll tree, re-attach — code, unit, integration, and E2E layers all present
- `uv run pytest --cov=app --cov-report=term-missing` → **3150 passed**, `app/` coverage **99%** (gate: >90%)
- `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` (isolated) → **3 passed** (47.6 s)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:** ① cancel endpoint 202/409/403 ✅ (unit + E2E) ② `cancelled` terminal reported by status, error null, counts kept ✅ (integration `test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean`) ③ unrequested-cancel pin ✅ (`test_unrequested_cancel_is_never_swallowed_and_never_converts`) ④ UI lifecycle + clean re-sync (sentinel row, full 40-file import) ✅ (E2E) ⑤ suite/coverage/E2E gates ✅ ⑥ uncancelled runs byte-identical ✅ (all 3150 pre-existing tests green untouched)

- Docs: README.md reviewed in full against code (pages, auth endpoints, every config default vs `app/config.py`, palette 9+8, fail-loud, cancel section) — consistent, no changes needed. AGENTS.md: fixed one pre-existing inaccuracy (rule 2 "move its file" → "move its directory" — phases are directories); everything else verified accurate
- No live infrastructure touched; no deviations

**Next pending phase:** `135_git_source_branch`
This commit is contained in:
2026-09-28 18:08:11 -04:00
parent 25de518dc9
commit fcac876d40
26 changed files with 2535 additions and 30 deletions
+130 -1
View File
@@ -75,6 +75,17 @@ orthogonal: it still calls the generator exactly as before
(``only_missing=True``, no change set). The fakes above record the
``changed_docs=`` kwarg (``FakeFolderSummaries.changed_docs_sets``) so
the wiring is asserted, not inferred.
Phase 134 (task 01): the cancel — anonymous 403 on the new
``POST /api/sync/cancel``; admin: 409 ``no sync is running`` idle and
after a terminal run, 202 ``cancel requested`` while a run is in
flight (the run is parked inside the import — the file's existing
gated-import pattern over the app's own event loop); the status then
reports the new ``cancelled`` terminal (``error`` null, ``detail``
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).
"""
from __future__ import annotations
@@ -111,12 +122,18 @@ from tests.fakes import FakeEmbedder
def _fresh_sync_state() -> Iterator[None]:
"""The module-level status object + task are process-global: reset them
around every test (both before — a previous test's terminal state
would leak into the idle assertion — and after)."""
would leak into the idle assertion — and after).
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."""
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
yield
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
@pytest.fixture(autouse=True)
@@ -422,6 +439,15 @@ def test_anonymous_gets_403_on_both_endpoints(client: TestClient) -> None:
assert r.json() == {"detail": "admin only"}
def test_anonymous_gets_403_on_cancel(client: TestClient) -> None:
"""Phase 134 (task 01): the cancel endpoint rides the router's
``require_admin`` dependency — anonymous gets the same 403 as the
other sync surface (the completion criterion's 403 pin)."""
r = client.post("/api/sync/cancel")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# --- admin: success --------------------------------------------------------
@@ -1864,3 +1890,106 @@ def test_sync_builds_include_hidden_map_by_root_string_with_or(
# string — the flag-off row's False is overridden by the True.
assert fake_import.sources == [[tmp_path / "bor" / "shared", tmp_path / "bor" / "shared"]]
assert fake_import.include_hidden_maps == [{shared: True}]
# --- phase 134 (task 01): the cancel (the fifth state) -------------------
def test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Phase 134 (task 01), the API contract end to end: a real run
started through the API is cancelled mid-run (parked inside the
import) → ``POST /api/sync/cancel`` answers 202 ``cancel
requested``, the status reports the new ``cancelled`` terminal
(``error`` null, ``detail`` empty, the final file counts kept, the
positions cleared, the PLAN §9 line logged); a cancel with no run
in flight is 409 ``no sync is running`` (idle AND after a
terminal); and the FOLLOWING run starts cleanly (202 — the stale
cancel event did not bleed in) and completes to ``success`` (the
restart after a partial cancel is clean — the importer's hash
delta re-imports what the cancel skipped)."""
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)
import_release = threading.Event()
gated_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
import_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)
# No run in flight (idle) → 409, the exact detail (the UI's
# harmless race — no error copy).
r = sync_client.post("/api/sync/cancel")
assert r.status_code == 409
assert r.json() == {"detail": "no sync is running"}
assert sync_client.post("/api/sync").status_code == 202
try:
assert gated_import.started.wait(5.0), "the run never reached the import"
# Parked inside the import (between files): the cancel request.
with caplog.at_level(logging.INFO, logger="app.api.sync"):
r = sync_client.post("/api/sync/cancel")
assert r.status_code == 202
assert r.json() == {"detail": "cancel requested"}
body = _poll(sync_client, "cancelled", timeout=5.0)
finally:
import_release.set()
assert body["error"] is None # a cancel is intent, not failure
assert body["detail"] == {}
assert body["started_at"] is not None
assert body["finished_at"] is not None
assert body["current_file"] is None
assert body["phase"] is None
assert body["current_summary"] is None
# The final file counts are kept (the phase-64/98 convention) ...
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
)
# The cancelled run never reached the version bump.
assert current_sources_version(db) == 0
# No run in flight anymore (the task is done) → 409 again.
r = sync_client.post("/api/sync/cancel")
assert r.status_code == 409
assert r.json() == {"detail": "no sync is running"}
# The restart is clean: a fresh run starts (202 — the stale event
# did not bleed in; start_sync cleared it) and the fresh (full)
# import completes to success.
second_import = FakeImportSources(
ImportSummary(files=3, added=1, updated=1, unchanged=1)
)
monkeypatch.setattr(sync_api, "import_sources", second_import)
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["error"] is None
assert body["detail"]["added"] == 1
assert body["detail"]["sources_version"] == 1 # only the completed run bumps
assert current_sources_version(db) == 1
assert len(second_import.sources) == 1
assert second_import.prune_flags == [True]