phase: 134_sync_cancel
**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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user