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:
@@ -1,36 +0,0 @@
|
||||
# Phase 134 — The owner can cancel a running sync from the Sources page
|
||||
|
||||
**Source:** `TODO.md` L3 — "Need to be able to cancel a sync after I've clicked the button."
|
||||
**Story:** n/a (TODO-derived phase, Protocol B append).
|
||||
**Context:** `app/api/sync.py` — `SyncStatus` (the four-state dataclass: `idle` / `running` / `success` / `failed`, phase-64 progress fields, phase-98 phase-machine fields), the module-level `_status` + `_task`, `POST /api/sync` (202 / 409 one-sync-at-a-time), `GET /api/sync/status`, and `_run_sync` — the in-process `asyncio` background task: the blocking git prelude (`scripts.git_sync.clone_or_pull` → `subprocess.run`, no await inside), then `await import_sources(...)` (per-file LLM awaits — a cancellation point between files), then `await regenerate_overview(...)` and `await generate_folder_summaries(...)` (both only on a changed KB / gap). The module docstring's shutdown contract: `CancelledError` is deliberately NOT swallowed — app shutdown cancels the task, and swallowing would mask a real stop. `import_sources` closes its own private session in a `finally` (leak-safe under a mid-import cancel). `frontend/assets/sources.js` — the sync-button lifecycle: `enterSyncRunningState` (live click + re-attach + every running tick), `settleSyncButton` (every terminal), `applySyncSuccess` / `applySyncFailure` / `applySyncIdle`, `startSyncPolling` (the 2 s two-job decision tree, 7 branches), `initSyncButton` (load-time re-attach). `frontend/index.html` — the `#sync-btn` markup (the Sources view section). E2E patterns: `tests/e2e/test_sync_button.py` (per-module app on a dedicated port, the `file://` fixture repo, the `_wait_sync_done` status-poll helper), `tests/e2e/slow_llm.py` (the delay-injecting proxy that makes a run's duration deterministic), `tests/e2e/mock_llm.py`. Unit: `tests/unit/test_sync_button.py`; integration: `tests/integration/test_sync_api.py`.
|
||||
|
||||
## Objective
|
||||
A sync that is already running can be stopped by the owner: while a run is in flight the Sources page shows a **Cancel** button; one click requests the cancel, the pipeline stops at the next boundary, the run lands in a new `cancelled` terminal state (reported by the status endpoint and rendered by the UI as a neutral result — cancel is intent, not failure), and the controls settle back to the retry-ready idle state. A subsequent sync starts cleanly (the partial run leaves a consistent KB — the importer's content-hash delta makes the next run idempotent).
|
||||
|
||||
## Dependencies
|
||||
- `133_pdf_screenshot_fallback` (todo) — pipeline order only; no code dependency.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **The fifth state — `cancelled`** (ASSUMPTION A1): `SyncStatus.state` becomes `Literal["idle", "running", "success", "failed", "cancelled"]`. It is TERMINAL like `success` / `failed`: carries `finished_at`, clears `current_file` / `phase` / `current_summary` but keeps the run's final `files_done` / `files_total` and summary counts (the phase-64/98 keep-final-counts convention), `detail = {}`, and **`error = None`** (no error string — the UI never treats it as a failure: no error banner, no error modal, just the neutral "Sync cancelled" result line). `GET /api/sync/status` gains nothing structurally — the `state` key simply reports `"cancelled"`.
|
||||
- **`POST /api/sync/cancel`** (same `router`, so `require_admin` — 403 anonymous): while a run is in flight → set the module-level `_cancel_event` (an `asyncio.Event`, next to `_status` / `_task`) AND call `_task.cancel()` (unblocks whatever `await` the pipeline is parked on — e.g. a per-file LLM call inside the import) → **202** `{"detail": "cancel requested"}`. No run in flight (`_task` None or done) → **409** `{"detail": "no sync is running"}`. `start_sync` resets `_cancel_event` (clear) when it starts a run.
|
||||
- **Cancellation semantics — cooperative, best-effort** (ASSUMPTION A2): `_run_sync` gains (a) a private `_check_cancelled()` helper that raises `asyncio.CancelledError()` when `_cancel_event.is_set()`, called at the pipeline boundaries — the top of the per-source loop (before each clone/pull or local-dir check), immediately before `import_sources(...)`, and immediately before the `if summary.added + summary.updated > 0:` block (covers the overview + summaries spans); and (b) an `except asyncio.CancelledError:` clause (placed before the existing `except Exception`): if `_cancel_event.is_set()` → land the run in the `cancelled` terminal state + one INFO log line (PLAN §9: `sync: cancelled by user state=cancelled`), then `raise` (the task ends cancelled — `_task.done()` is True, the status object stays authoritative); otherwise (no user cancel — the app-shutdown path) → `raise` unchanged (the module docstring's shutdown contract holds: an unrequested cancellation is never swallowed). Consequence the UI/tests may rely on: a git subprocess already running at the cancel moment runs to completion (the blocking `subprocess.run` has no await inside) — the cancel takes effect at the NEXT boundary (after that source's git ops, between files, or before overview/summaries). During the import span the `_task.cancel()` from the endpoint interrupts at the next per-file await.
|
||||
- **The UI** (ASSUMPTION A3): a `#sync-cancel-btn` button in the same sync-control area as `#sync-btn` (`frontend/index.html`), `hidden` by default; secondary button styling consistent with the theme (contrast ≥4.5:1, `:focus-visible` ring — house UI rules). `sources.js` rules: SHOWN + ENABLED only while a SYNC run is running (`enterSyncRunningState` with `kind === "sync"` — the upload job is NOT cancellable in this phase, so the cancel button stays hidden while only the upload run drives the button); HIDDEN + DISABLED by `settleSyncButton` (every terminal/idle settle) and by every `applySync*` path. Click → `requestCancel()`: the cancel button disables IMMEDIATELY (never stale, no double request), `POST /api/sync/cancel`; 403 → the whoami-backstop hide-everything branch (same as the other endpoints); 409 → a harmless race (the run ended between ticks — the poll settles on the next tick, no error copy); 202 → the button stays disabled until the terminal (the poll's new `cancelled` branch settles everything); any other non-2xx → the phase-55 neutral retry copy in `#sync-result` ("Couldn't cancel the sync — try again.") + the cancel button re-enables. New poll-tree branch (after the `failed` branch, before the idle branch): `syncStatus.state === "cancelled"` → stop polling, settle the main button to "Sync sources", `#sync-result` = "Sync cancelled" (the aria-live announcer reads it), hide the error banner, hide the cancel button, `emitSyncStatus`. `initSyncButton` re-attach: `state === "cancelled"` → the same neutral settle (a page reload during/right after a cancel shows "Sync cancelled", never a stuck running state).
|
||||
|
||||
## Tasks
|
||||
1. `01_backend_cancel_state.md` — the `cancelled` state, `POST /api/sync/cancel`, and the cooperative cancel mechanics in `_run_sync`.
|
||||
2. `02_frontend_cancel_button.md` — the `#sync-cancel-btn` UI: show/hide rules, the cancel request lifecycle, the `cancelled` terminal branch in the poll tree + re-attach.
|
||||
3. `03_e2e_sync_cancel.md` — the dedicated Playwright E2E (the slow-LLM cancel window, the API contract, the full UI lifecycle, the clean re-sync after cancel).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the cancel endpoint's contract (202/409/403), `_check_cancelled`, the `CancelledError` conversion (event set → `cancelled` state + re-raise; event unset → re-raise with the state untouched), the `SyncStatus` reset on a fresh start (a stale cancel event cannot bleed into the next run).
|
||||
- Integration: a real run started through the API is cancelled mid-run → the status reports `cancelled` (with `error: None`); a second run after the cancel completes successfully (the restart is clean).
|
||||
- E2E: one dedicated Playwright file, run in isolation (`tests/e2e/test_sync_cancel.py`) — the mock-only app with the `slow_llm` proxy for a deterministic cancel window.
|
||||
- Coverage: **>90%** on new/modified code (`app/`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `POST /api/sync/cancel` (admin): 202 while a run is in flight, 409 `no sync is running` otherwise, 403 anonymous
|
||||
- [ ] a cancelled run lands in the `cancelled` terminal state (`error: None`, final counts kept) and the status endpoint reports it
|
||||
- [ ] the app-shutdown cancel contract is preserved (an unrequested `CancelledError` is never swallowed — a unit test pins it)
|
||||
- [ ] the UI: the Cancel button is visible exactly while a sync run is running, the `cancelled` terminal settles to "Sync cancelled" with no error banner/modal, and a re-sync after a cancel succeeds
|
||||
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` green in isolation
|
||||
- [ ] no behavior change in completed phases (the uncancelled run is byte-identical: same states, same labels, same 409 double-trigger)
|
||||
@@ -1,63 +0,0 @@
|
||||
# Task 01 — Backend: the `cancelled` state, `POST /api/sync/cancel`, and the cooperative cancel in `_run_sync`
|
||||
|
||||
**Phase:** `134_sync_cancel` · **Source:** `TODO.md:3` — "Need to be able to cancel a sync after I've clicked the button."
|
||||
|
||||
## Objective
|
||||
The sync API can stop its own in-flight run: a new admin-only `POST /api/sync/cancel` requests the cancel; `_run_sync` converts a user-requested cancellation into a new `cancelled` terminal state (never an error); the shutdown-cancel contract is untouched.
|
||||
|
||||
## Work
|
||||
1. `app/api/sync.py` — the state machine:
|
||||
- `SyncStatus.state`: `Literal["idle", "running", "success", "failed", "cancelled"]` (the docstring's four-state paragraph becomes five; `cancelled` is terminal like `success` / `failed`).
|
||||
- Module level, next to `_status` / `_task`: `_cancel_event = asyncio.Event()`.
|
||||
- `start_sync`: reset the run's cancellation — `_cancel_event.clear()` before `_task = asyncio.create_task(_run_sync())` (a stale event from a previous cancelled run must never bleed into the next run).
|
||||
2. `app/api/sync.py` — the endpoint (same `router` → `require_admin` applies, 403 anonymous):
|
||||
```python
|
||||
@router.post("/cancel", status_code=202)
|
||||
async def cancel_sync() -> dict[str, str]:
|
||||
if _task is None or _task.done():
|
||||
raise HTTPException(status_code=409, detail="no sync is running")
|
||||
_cancel_event.set()
|
||||
_task.cancel()
|
||||
return {"detail": "cancel requested"}
|
||||
```
|
||||
202 is answered the moment the request is REGISTERED (the run may already be terminal — the UI's poll then settles on the terminal state; a 202-then-immediate-404-style race is impossible because the status object, not the task, is authoritative).
|
||||
3. `app/api/sync.py` — `_run_sync` mechanics:
|
||||
- A private helper: `def _check_cancelled() -> None:` — `if _cancel_event.is_set(): raise asyncio.CancelledError()`.
|
||||
- Call it at the boundaries: (a) the top of the per-source loop (before the `kind == "git"` / `kind == "local"` split), (b) immediately before `await import_sources(...)`, (c) immediately before the `if summary.added + summary.updated > 0:` block (covers both the overview and the summaries spans).
|
||||
- New `except asyncio.CancelledError:` clause placed BEFORE the existing `except Exception`:
|
||||
```python
|
||||
except asyncio.CancelledError:
|
||||
if _cancel_event.is_set():
|
||||
_status.state = "cancelled"
|
||||
_status.finished_at = datetime.now(UTC)
|
||||
_status.error = None
|
||||
_status.current_file = None
|
||||
_status.phase = None
|
||||
_status.current_summary = None
|
||||
logger.info(
|
||||
"sync: cancelled by user state=cancelled files_done=%d files_total=%d",
|
||||
_status.files_done, _status.files_total,
|
||||
)
|
||||
raise
|
||||
raise # app shutdown — the module-docstring contract: never swallow an unrequested cancel
|
||||
```
|
||||
The terminal keeps `files_done` / `files_total` and the summary counts (the phase-64/98 keep-final-counts convention); `detail` stays `{}` (a partial run reports no counts — ASSUMPTION A1: `error` is None, `detail` is empty).
|
||||
- Update the module docstring: the state machine is five-state; the cancel semantics are cooperative (a git subprocess in flight at the cancel moment runs to completion — the cancel lands at the next boundary); the `CancelledError` paragraph now names BOTH conversion paths (user-requested → `cancelled` state + re-raise; unrequested → re-raise).
|
||||
4. `GET /api/sync/status` — no shape change; update its docstring (the `state` value set now includes `cancelled`; `error` is null in that state).
|
||||
5. Unit tests — `tests/unit/test_sync_button.py` (extend) or a new `tests/unit/test_sync_cancel.py` (your call — the existing file owns the endpoint's contract, so extending it is the default):
|
||||
- `POST /api/sync/cancel` anonymous → 403; idle (never started, and after a terminal run) → 409 with the exact detail `no sync is running`.
|
||||
- A started run: the endpoint returns 202 `{"detail": "cancel requested"}`, sets the event, and cancels the task (drive `_run_sync` with a controllable await — e.g. a monkeypatched `import_sources` that awaits a `asyncio.Event` the test controls — then cancel via the endpoint and assert the task finished + `_status.state == "cancelled"`, `error is None`, `finished_at` set, final counts kept, `detail == {}`).
|
||||
- The shutdown contract pin: cancel the task WITHOUT setting the event (bare `task.cancel()`) → `_status.state` is NOT `cancelled` (stays `running` — the task died mid-run without a terminal state, exactly as today's shutdown path does) and the `CancelledError` propagated.
|
||||
- The stale-event pin: a run that was cancelled, then a FRESH run starts → `_cancel_event` is cleared (the new run completes to `success` with no cancel request).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the behaviors in Work step 5 (the suite above IS the task's unit layer).
|
||||
- Integration: `tests/integration/test_sync_api.py` (extend) — start a real run against the `file://` fixture pattern the file already uses (a slow/many-file fixture or a monkeypatched delay at the per-file hook seam — follow the file's existing fakes), `POST /api/sync/cancel` while running → poll `GET /api/sync/status` → `state == "cancelled"` (and `error is None`); then a second full run → `state == "success"` (the restart after a partial cancel is clean — the importer's hash delta re-imports what the cancel skipped).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_sync_button.py tests/integration/test_sync_api.py -v` green (plus the new test file, if split)
|
||||
- [ ] the five-state machine is exercised in tests: `cancelled` is reachable ONLY through a user-requested cancel (the unrequested-cancel pin is green)
|
||||
- [ ] the module docstring names the cooperative boundary semantics (a future reader sees why a long clone can outlive the click)
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no behavior change for the uncancelled run (the existing sync tests stay green untouched)
|
||||
@@ -1,45 +0,0 @@
|
||||
# Task 02 — Frontend: the `#sync-cancel-btn` and the `cancelled` terminal branch in the poll tree
|
||||
|
||||
**Phase:** `134_sync_cancel` · **Source:** `TODO.md:3` — "Need to be able to cancel a sync after I've clicked the button."
|
||||
|
||||
## Objective
|
||||
The Sources page shows a Cancel button exactly while a sync run is in flight (live click, re-attached run, every running poll tick); clicking it requests the cancel and the UI settles into the neutral "Sync cancelled" terminal — no error banner, no modal, never stale.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` (the Sources view section, next to `#sync-btn`): add the cancel control —
|
||||
```html
|
||||
<button type="button" id="sync-cancel-btn" class="sync-cancel-btn" hidden>Cancel</button>
|
||||
```
|
||||
- `hidden` by default (anonymous-safe + the idle state; the module reveals it, the same convention as `#sync-btn`).
|
||||
- A visible "Cancel" label (no icon-only — WCAG 2.4 name/role; the accessible name is the text).
|
||||
- `frontend/assets/styles.css`: `.sync-cancel-btn` — a secondary button style consistent with the theme (the house tokens, contrast ≥4.5:1 on `--surface`, `:focus-visible` ring like the other buttons, disabled state). No layout regression on the sync-control row (mobile included — the row already wraps; verify the 360 px case).
|
||||
2. `frontend/assets/sources.js` — the lifecycle (all inside the existing sync section; the §7.4 never-stale contract applies exactly as to the main button):
|
||||
- Element lookup: `const syncCancelBtn = root.querySelector("#sync-cancel-btn");`
|
||||
- Show/hide helpers: `showSyncCancelBtn()` (unhide + enable + `aria-label="Cancel sync"`) / `hideSyncCancelBtn()` (hide + disable).
|
||||
- `enterSyncRunningState(kind, …, status)`: when `kind === "sync"` → `showSyncCancelBtn()`; when `kind === "upload"` → `hideSyncCancelBtn()` (the upload job is NOT cancellable in this phase — the cancel button belongs to the sync run only).
|
||||
- `settleSyncButton(label)`: always `hideSyncCancelBtn()` (every terminal/idle settle — sync success/failed/cancelled, upload success/failed, both idle).
|
||||
- `requestCancel()` + the click listener (armed next to `syncBtn.addEventListener("click", startSync)`):
|
||||
- `syncCancelBtn.disabled = true` FIRST (never stale, one request at a time), `#sync-result` cleared.
|
||||
- `POST /api/sync/cancel`: 403 → the whoami-backstop branch (stop polling, hide the main button, `applySyncIdle()`) — the same depth as every other 403 in this module; 409 → NO-OP (the run ended between the tick and the click — the poll settles on the next tick; the cancel button is hidden by that settle; no error copy); 202 → nothing (the button stays disabled — the poll's `cancelled` branch settles everything); any other non-2xx / network failure → `#sync-result` = "Couldn't cancel the sync — try again." (the phase-55 neutral retry copy) + `syncCancelBtn.disabled = false` (the run is still going — the owner can retry).
|
||||
- New terminal branch in `startSyncPolling`'s tick — placed AFTER the `failed` branch and BEFORE the upload-success branch:
|
||||
```js
|
||||
if (syncStatus.state === "cancelled") {
|
||||
stopSyncPolling();
|
||||
applySyncCancelled(syncStatus);
|
||||
return;
|
||||
}
|
||||
```
|
||||
- `applySyncCancelled(status)`: `settleSyncButton("Sync sources")` (re-enables the main button, hides the cancel button), `syncResult.textContent = "Sync cancelled"`, `hideSyncError()`, `emitSyncStatus(status)`. No modal, no error styling (ASSUMPTION A1 — cancel is intent).
|
||||
- `initSyncButton` re-attach: `status.state === "cancelled"` → `applySyncCancelled(status)` (a reload during/right after a cancel shows the neutral result, never a stuck running state).
|
||||
- Update the section-header docstring (the decision tree gains the `cancelled` branch — the tree's "7" becomes "8" — and the cancel button's show/hide contract is documented there, the house pattern).
|
||||
3. No change to the upload job, the main sync button's existing labels, or the `bor:sync-status` event shape (the `cancelled` state simply flows through `emitSyncStatus` — the event's `detail` is the status object, as for every other state).
|
||||
|
||||
## Testing & Quality
|
||||
- No JS unit layer in this repo — the UI contract is pinned by the phase's E2E (task 03) and by the never-stale rules above.
|
||||
- Coverage: **>90%** on new/modified code (test-only-for-`app/` task — the bar is carried by the suite run; the JS is covered by the E2E).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the Cancel button is visible + enabled ONLY while a sync run is running (hidden on boot/idle, hidden while only the upload run drives the button, hidden on every terminal)
|
||||
- [ ] clicking Cancel settles to "Sync cancelled" in `#sync-result` with the main button re-enabled, the cancel button hidden, no error banner and no modal
|
||||
- [ ] a 409 cancel (race) is silent; a failed cancel request shows the neutral retry copy and re-enables the button (the run keeps going)
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean; the page renders with no console errors (verified in the E2E, task 03)
|
||||
@@ -1,36 +0,0 @@
|
||||
# Task 03 — E2E: `tests/e2e/test_sync_cancel.py` (the dedicated Playwright suite, run in isolation)
|
||||
|
||||
**Phase:** `134_sync_cancel` · **Source:** `TODO.md:3` — "Need to be able to cancel a sync after I've clicked the button."
|
||||
|
||||
## Objective
|
||||
The owner's cancel path is pinned end to end against the REAL pipeline: a real `file://` fixture sync whose duration is long enough to cancel (the `slow_llm` proxy), the API contract (403/409/202), the UI lifecycle (Cancel button visible → click → "Sync cancelled" settle), and the clean re-sync after a partial run.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_sync_cancel.py` (new) — the `test_sync_button.py` module-app pattern, adapted:
|
||||
- **Ports/env:** per-module app on its own port — `APP_PORT = int(os.environ.get("E2E_APP_PORT_SYCNCEL", "8133"))`; `BOR_STATIC_DIR`, `BOR_RELEVANCE_THRESHOLD=0.30` (mock-calibrated, the conftest pattern), `BOR_ADMIN_PASSWORD` / `BOR_SESSION_SECRET` from `e2e.conftest`, `BOR_GIT_SOURCES=file://<fixture repo>`, a dedicated `BOR_SOURCES_DIR`.
|
||||
- **The slow-LLM proxy:** spawn `tests/e2e/slow_llm.py` the conftest way (the `SLOW_LLM_DELAY_S` env — 0.3 s is a good default: ~40 fixture files → ~12+ s of import — a wide, deterministic cancel window against the UI's 2 s poll) in front of the mock LLM (the `mock_llm` fixture from `e2e.conftest`); `BOR_LLM_BASE_URL` points at the proxy.
|
||||
- **The fixture repo** (module-scoped, `tmp_path_factory`, real `git` subprocess — the `sync_git_repo` pattern): ~40 small A9-format `.md` files under a `notes/` folder (each one unique enough to import independently; ONE sentinel file — `Marker: RESE-CANCEL-SENTINEL-4f1e` — the assertion target for the re-sync).
|
||||
- **`_wait_sync_state(page, app_url, states, timeout_s)`** — the `test_sync_button._wait_sync_done` helper generalized to a set of terminal states (here `{"cancelled"}` and `{"success", "failed"}` for the re-sync).
|
||||
2. Tests (Playwright Mapping Rule — each test names the behavior it pins):
|
||||
- `test_anonymous_cancel_403` — anonymous: `POST /api/sync/cancel` → 403 (the admin-only surface, same as the other two endpoints); `#sync-cancel-btn` is never revealed (the whoami gate).
|
||||
- `test_cancel_when_idle_409` — logged-in, no run in flight: `POST /api/sync/cancel` → 409 with the exact detail `no sync is running`; the Cancel button is hidden in the UI (boot + idle).
|
||||
- `test_admin_cancel_lifecycle` — the owner's exact flow:
|
||||
1. login (lands on `/sources.html`);
|
||||
2. click `#sync-btn` → `#sync-label` "Syncing…", the main button disabled, **`#sync-cancel-btn` visible + enabled** (within the 2 s poll tick — the running state is already entered synchronously by the 202);
|
||||
3. confirm via the status endpoint that the run is `running` (the deterministic layer — `page.request` poll at ~100 ms, the `slow_llm` module's convention);
|
||||
4. click `#sync-cancel-btn` → the cancel button disables immediately (never stale);
|
||||
5. `_wait_sync_state(…, {"cancelled"})` → the status endpoint reports `state == "cancelled"` with `error is None`;
|
||||
6. the UI settles (timeout-bound): main button ENABLED + label "Sync sources", `#sync-cancel-btn` hidden, `#sync-result` text "Sync cancelled", the error banner hidden, no error modal in the DOM;
|
||||
7. **the re-sync is clean:** click `#sync-btn` again → "Synced HH:MM" (the `SYNCED_LABEL` regex) + `#sync-result` includes "40 added" (or the fixture's file count — the first FULL pass; the cancelled run's partial import is idempotent under the hash delta — assert the sentinel row is present in the catalog via the drill-down tree, the `_expect_fixture_row` pattern) → the cancel never corrupted the KB.
|
||||
- Wall-time guard: the whole module < 120 s (40 files × 0.3 s proxy delay + the re-sync's 40 × 0.3 s — if it drifts, cut the file count, not the guard).
|
||||
3. Header docstring: the story ("the owner's TODO item, L3"), the run command (`uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov`), the isolation note (per-module app, dedicated port — the combined-run convention), the slow-LLM provenance (the proxy is the only thing that makes the cancel window deterministic).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: this file IS the task's suite; it runs in ISOLATION (`--no-cov`, the AGENTS.md rule 9 gate) with the DB up (`podman compose up -d db`) and git on PATH.
|
||||
- Coverage: **>90%** on new/modified code (test-only task — the bar is carried by the suite run).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` green in isolation
|
||||
- [ ] the cancel window is deterministic (the test does not race the 2 s poll — the `slow_llm` delay is the gate; a rerun on a quiet machine passes)
|
||||
- [ ] the re-sync after a cancel imports the FULL fixture (the sentinel row present — a partial cancel never leaves a corrupted or permanently-incomplete KB)
|
||||
- [ ] the `cancelled` state's `error` is null end to end (the UI can never render it as a failure)
|
||||
Reference in New Issue
Block a user