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:
+185
-7
@@ -328,6 +328,39 @@
|
||||
* "Syncing… <file> (n/m)", "Importing", "Writing KB overview…",
|
||||
* "Summarizing folders… <folder> (n/m)"); textContent only — no HTML
|
||||
* strings (the house rule).
|
||||
*
|
||||
* Phase 134 (task 02) — the Cancel control + the cancelled terminal:
|
||||
* the owner can stop a running sync. #sync-cancel-btn (the static
|
||||
* markup next to #sync-btn — ship-hidden, the same convention) is
|
||||
* visible + enabled ONLY while a SYNC run is in flight:
|
||||
* enterSyncRunningState shows it for kind "sync" (the live click, the
|
||||
* load-time re-attach, and every running poll tick) and hides it for
|
||||
* kind "upload" (the upload job is NOT cancellable in this phase —
|
||||
* its run keeps the control hidden); settleSyncButton hides it on
|
||||
* EVERY settle (every terminal + both idles — one choke point). A
|
||||
* click runs requestCancel() under the same never-stale contract as
|
||||
* the main button: the control disables IMMEDIATELY (one request at
|
||||
* a time — no double fire) BEFORE the POST /api/sync/cancel —
|
||||
* 403 → the whoami-backstop hide-everything branch (the same depth as
|
||||
* every other 403 in this module), 409 → SILENT (the run ended
|
||||
* between the tick and the click — the poll settles on the next tick,
|
||||
* which hides the control; no error copy), 202 → the control stays
|
||||
* disabled until the terminal (the poll's new cancelled branch settles
|
||||
* everything — the status object, not the request, is authoritative),
|
||||
* any other non-2xx / network failure → the phase-55 neutral retry
|
||||
* copy ("Couldn't cancel the sync — try again.") + re-enable (the
|
||||
* run is still going — the owner can retry). The poll tree gains its
|
||||
* 5th branch (the 7-branch tree becomes 8): state "cancelled" — after
|
||||
* the failed branch, before the upload branches — stops the poll and
|
||||
* runs applySyncCancelled: the NEUTRAL result (A1 — cancel is intent,
|
||||
* not failure): settle the main button retry-ready (which also hides
|
||||
* the Cancel control), "Sync cancelled" on the aria-live result line,
|
||||
* the error banner hidden, the status frame emitted — no modal, no
|
||||
* error styling. The initSyncButton re-attach renders the same result
|
||||
* for a reload during/right after a cancel (a page reload shows
|
||||
* "Sync cancelled", never a stuck running state). The bor:sync-status
|
||||
* event shape is unchanged — the cancelled status object flows through
|
||||
* emitSyncStatus exactly like every other state.
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -606,14 +639,41 @@ export async function mount(root) {
|
||||
* sources" contract, A3);
|
||||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||||
* 5. sync cancelled → the phase-134 neutral terminal (task 02 —
|
||||
* A1: cancel is intent, not failure): settle
|
||||
* the main button retry-ready + "Sync
|
||||
* cancelled" on #sync-result, no banner / no
|
||||
* modal (applySyncCancelled); the settle also
|
||||
* hides the Cancel control;
|
||||
* 6. upload success → settle "Sync sources" + catalog refresh
|
||||
* (loadTree — phase 90: an upload no longer
|
||||
* changes the KB, the re-read is a no-op safety
|
||||
* net); the upload's result line lives on the
|
||||
* Sources page, never in #sync-result (A3);
|
||||
* 6. upload failed → settle "Sync sources" — the failure is the
|
||||
* 7. upload failed → settle "Sync sources" — the failure is the
|
||||
* Sources page's error banner, never this page's (A3);
|
||||
* 7. both idle → retry-ready idle.
|
||||
* 8. both idle → retry-ready idle.
|
||||
* Phase 134 (task 02) — the Cancel control (#sync-cancel-btn, the
|
||||
* static markup next to #sync-btn — ship-hidden, the same
|
||||
* convention): visible + enabled ONLY while a SYNC run is in
|
||||
* flight. The show/hide contract has two choke points:
|
||||
* enterSyncRunningState shows it for kind "sync" (the live 202/409
|
||||
* click, the load-time re-attach, and every running tick of branch
|
||||
* 1) and hides it for kind "upload" (the upload job is NOT
|
||||
* cancellable in this phase — its run keeps the control hidden);
|
||||
* settleSyncButton hides it on EVERY settle (branches 3–8 all pass
|
||||
* through it). The click runs requestCancel(): the control disables
|
||||
* IMMEDIATELY (one request at a time — never stale) before
|
||||
* POST /api/sync/cancel; 403 → the whoami-backstop branch (stop
|
||||
* polling, hide the main button, settle idle — the same depth as
|
||||
* every other 403 in this module); 409 → SILENT (the run ended
|
||||
* between the tick and the click — the poll settles on the next
|
||||
* tick and its settle hides the control; no error copy); 202 →
|
||||
* nothing (the control stays disabled — the poll's branch 5 settles
|
||||
* everything; the run may already be terminal by then — the status
|
||||
* object, not the request, is authoritative); any other non-2xx /
|
||||
* network failure → the phase-55 neutral retry copy on #sync-result
|
||||
* + re-enable (the run is still going — the owner can retry).
|
||||
* The live label is the status endpoint's full source/relative/path
|
||||
* (A4) — or, in the post-import phases, the phase's own copy (Phase
|
||||
* 98 task 02): CSS ellipsizes #sync-label; the full untruncated
|
||||
@@ -632,11 +692,18 @@ 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-icon (the
|
||||
* import phase after the file's first batch)), #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-result (aria-live result line),
|
||||
* #sync-error-banner / #sync-error-text (error banner).
|
||||
*/
|
||||
const syncBtn = root.querySelector("#sync-btn");
|
||||
// Phase 134 (task 02): the Cancel control — visible + enabled ONLY
|
||||
// while a SYNC run is in flight (the section header carries the
|
||||
// show/hide contract); the ship-hidden attribute stays until the
|
||||
// first running sync state.
|
||||
const syncCancelBtn = root.querySelector("#sync-cancel-btn");
|
||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||
const syncLabelMain = syncLabel ? syncLabel.querySelector(".sync-label-main") : null;
|
||||
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
|
||||
@@ -841,10 +908,19 @@ export async function mount(root) {
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||
// Phase 134 (task 02): the Cancel control is visible + enabled
|
||||
// ONLY while a SYNC run is in flight — the upload job is NOT
|
||||
// cancellable in this phase (its run keeps the control hidden).
|
||||
if (kind === "sync") showSyncCancelBtn();
|
||||
else hideSyncCancelBtn();
|
||||
}
|
||||
|
||||
function settleSyncButton(label) {
|
||||
if (!syncBtn) return;
|
||||
// Phase 134 (task 02): EVERY settle hides the Cancel control —
|
||||
// every terminal (sync success/failed/cancelled, upload
|
||||
// success/failed) and both idle settles pass through here.
|
||||
hideSyncCancelBtn();
|
||||
syncBtn.disabled = false;
|
||||
syncBtn.removeAttribute("aria-busy");
|
||||
syncBtn.removeAttribute("title");
|
||||
@@ -868,6 +944,27 @@ export async function mount(root) {
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||
}
|
||||
|
||||
/* Phase 134 (task 02): the Cancel control's show/hide — visible +
|
||||
* enabled ONLY while a SYNC run is in flight (the upload job is NOT
|
||||
* cancellable in this phase). Show = unhide + enable + the aria
|
||||
* label (the accessible name stays stable — the visible "Cancel"
|
||||
* text is the WCAG 2.4 name); hide = hide + disable, so a hidden
|
||||
* control is never focusable or activatable in any state (the
|
||||
* never-stale contract — the settle paths below are the only hides,
|
||||
* and they run on every terminal). */
|
||||
function showSyncCancelBtn() {
|
||||
if (!syncCancelBtn) return;
|
||||
syncCancelBtn.hidden = false;
|
||||
syncCancelBtn.disabled = false;
|
||||
syncCancelBtn.setAttribute("aria-label", "Cancel sync");
|
||||
}
|
||||
|
||||
function hideSyncCancelBtn() {
|
||||
if (!syncCancelBtn) return;
|
||||
syncCancelBtn.hidden = true;
|
||||
syncCancelBtn.disabled = true;
|
||||
}
|
||||
|
||||
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||
let syncModal = null;
|
||||
let syncModalReturnFocus = null;
|
||||
@@ -936,6 +1033,23 @@ export async function mount(root) {
|
||||
showSyncModal(error);
|
||||
}
|
||||
|
||||
/* Phase 134 (task 02): the cancelled terminal — the NEUTRAL result
|
||||
* (A1: cancel is intent, not failure — no error banner, no modal,
|
||||
* no error styling): settle the main button retry-ready (which also
|
||||
* hides the Cancel control), announce "Sync cancelled" on the
|
||||
* aria-live result line, hide the error banner (a failed run's
|
||||
* banner must not survive the cancel), and emit the status frame
|
||||
* (the bor:sync-status detail is the status object, as for every
|
||||
* state). The settle's "Sync sources" label lands on #sync-result
|
||||
* FIRST and is superseded by the result line here — the main
|
||||
* button's title keeps "Sync sources" (the retry-ready name). */
|
||||
function applySyncCancelled(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
if (syncResult) syncResult.textContent = "Sync cancelled";
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
}
|
||||
|
||||
function applySyncIdle(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
emitSyncStatus(status || { state: "idle" });
|
||||
@@ -1005,7 +1119,16 @@ export async function mount(root) {
|
||||
applySyncFailure(syncStatus);
|
||||
return;
|
||||
}
|
||||
// 5. upload success: settle + catalog refresh (A3 — the upload's
|
||||
// 5. sync cancelled: the NEUTRAL terminal (phase 134 task 02 —
|
||||
// A1: cancel is intent, not failure — no banner, no modal):
|
||||
// the "Sync cancelled" result line + the retry-ready main
|
||||
// button; the settle also hides the Cancel control.
|
||||
if (syncStatus.state === "cancelled") {
|
||||
stopSyncPolling();
|
||||
applySyncCancelled(syncStatus);
|
||||
return;
|
||||
}
|
||||
// 6. upload success: settle + catalog refresh (A3 — the upload's
|
||||
// counts live on the Sources page; #sync-result stays empty).
|
||||
if (uploadStatus && uploadStatus.state === "success") {
|
||||
stopSyncPolling();
|
||||
@@ -1016,7 +1139,7 @@ export async function mount(root) {
|
||||
loadTree();
|
||||
return;
|
||||
}
|
||||
// 6. upload failed: settle only — the failure is the Sources page's
|
||||
// 7. upload failed: settle only — the failure is the Sources page's
|
||||
// error banner, never this page's (A3).
|
||||
if (uploadStatus && uploadStatus.state === "failed") {
|
||||
stopSyncPolling();
|
||||
@@ -1026,7 +1149,7 @@ export async function mount(root) {
|
||||
emitSyncStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
// 7. both idle: settle retry-ready.
|
||||
// 8. both idle: settle retry-ready.
|
||||
stopSyncPolling();
|
||||
applySyncIdle(syncStatus);
|
||||
};
|
||||
@@ -1068,6 +1191,49 @@ export async function mount(root) {
|
||||
});
|
||||
}
|
||||
|
||||
/* Phase 134 (task 02): the cancel request — the §7.4 never-stale
|
||||
* contract applies exactly as to the main button: the control
|
||||
* disables IMMEDIATELY (one request at a time — no double fire)
|
||||
* BEFORE the fetch, and #sync-result clears with it.
|
||||
* • 403 → the whoami-backstop branch (stop polling, hide the
|
||||
* main button, settle idle) — 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 (its settle hides
|
||||
* the control); no error copy, no state change;
|
||||
* • 202 → nothing: the control stays disabled — the poll's
|
||||
* `cancelled` branch settles everything (the run may
|
||||
* already be terminal by the next tick — the status
|
||||
* object, not the request, is authoritative);
|
||||
* • any other non-2xx / network failure → the phase-55 neutral
|
||||
* retry copy on #sync-result + the control re-enables (the run
|
||||
* is still going — the owner can retry). */
|
||||
async function requestCancel() {
|
||||
if (!syncCancelBtn) return;
|
||||
syncCancelBtn.disabled = true; // FIRST — never stale, one request at a time
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/sync/cancel", { method: "POST" });
|
||||
} catch {
|
||||
// Network failure: the run is still going — the phase-55
|
||||
// neutral retry copy + the control re-enables (retryable).
|
||||
if (syncResult) syncResult.textContent = "Couldn't cancel the sync — try again.";
|
||||
syncCancelBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (r.status === 403) {
|
||||
stopSyncPolling();
|
||||
if (syncBtn) syncBtn.hidden = true;
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (r.status === 409) return; // race: the run ended — the poll settles
|
||||
if (r.ok) return; // 202: the poll's cancelled branch settles everything
|
||||
if (syncResult) syncResult.textContent = "Couldn't cancel the sync — try again.";
|
||||
syncCancelBtn.disabled = false; // the run is still going — retry
|
||||
}
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
||||
* state, a terminal run renders its last result. Phase 64 (A3): with
|
||||
* the sync IDLE, an in-flight background upload RUN (phase 90: unpack
|
||||
@@ -1106,6 +1272,12 @@ export async function mount(root) {
|
||||
applySyncFailure(status);
|
||||
return;
|
||||
}
|
||||
// Phase 134 (task 02): a reload during/right after a cancel shows
|
||||
// the neutral result — never a stuck running state.
|
||||
if (status.state === "cancelled") {
|
||||
applySyncCancelled(status);
|
||||
return;
|
||||
}
|
||||
// Sync idle: check the SECOND job — an in-flight upload run
|
||||
// re-attaches (the bare "Importing…" label — phase 90: unpack +
|
||||
// register only).
|
||||
@@ -1129,6 +1301,12 @@ export async function mount(root) {
|
||||
syncBtn.addEventListener("click", startSync);
|
||||
initSyncButton();
|
||||
}
|
||||
// Phase 134 (task 02): the Cancel control — armed next to the main
|
||||
// button (the control is visible only while a sync run is in
|
||||
// flight, so it can never be reached in any other state).
|
||||
if (syncCancelBtn) {
|
||||
syncCancelBtn.addEventListener("click", requestCancel);
|
||||
}
|
||||
|
||||
const tbody = root.querySelector("#docs-tbody");
|
||||
const emptyEl = root.querySelector("#sources-empty");
|
||||
|
||||
@@ -2130,6 +2130,41 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||
/* Phase 134 (task 02): the Cancel control — the in-flight sync run's
|
||||
cooperative stop (visible ONLY while a SYNC run is in flight; the
|
||||
upload job is not cancellable in this phase — sources.js owns the
|
||||
show/hide, the same never-stale contract as the main button). The
|
||||
SECONDARY pill language of the house button family (the
|
||||
.kb-summary-cancel pair): transparent fill, 1px --line border,
|
||||
--ink-soft text (5.1:1 on --surface, AA), the global 3px
|
||||
:focus-visible ring, the >=44px touch floor. :disabled = the cancel
|
||||
request in flight (one request at a time — never stale); the hover
|
||||
is guarded on :not(:disabled) so a disabled button under the cursor
|
||||
never reads as actionable. The [hidden] rule keeps the attribute
|
||||
authoritative over the display above (the .kb-summary-edit pattern).
|
||||
No layout regression on the sync-control row: the row already
|
||||
flexes with a 1rem gap (desktop) and wraps (mobile, where the pill
|
||||
goes full-width like the main button — the 360px case). */
|
||||
.sync-cancel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sync-cancel-btn:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.sync-cancel-btn:disabled { opacity: 0.6; cursor: default; } /* the cancel request is in flight */
|
||||
.sync-cancel-btn[hidden] { display: none; } /* the hidden attr must beat the display above */
|
||||
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
|
||||
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -5421,6 +5456,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
the accessible names. */
|
||||
.page-head-row { flex-wrap: wrap; }
|
||||
.sync-btn { width: 100%; }
|
||||
.sync-cancel-btn { width: 100%; } /* phase 134: the Cancel pill stacks full-width under the Sync pill (the row already wraps) */
|
||||
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
|
||||
.history-refresh { width: 100%; }
|
||||
.history-refresh svg { display: block; }
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label"><span class="sync-label-main">Sync sources</span><span class="sync-label-count" hidden></span><span class="sync-label-chunk" hidden></span></span>
|
||||
</button>
|
||||
<!-- Phase 134 (task 02): the Cancel control — visible +
|
||||
enabled ONLY while a sync run is in flight (the upload
|
||||
job is not cancellable in this phase). Ships hidden
|
||||
(anonymous-safe + the idle state; sources.js reveals
|
||||
it the same way it reveals #sync-btn) and requests
|
||||
the cancel on click (POST /api/sync/cancel). The
|
||||
visible "Cancel" label is the accessible name (WCAG
|
||||
2.4 — never an icon-only control). -->
|
||||
<button type="button" id="sync-cancel-btn" class="sync-cancel-btn" hidden>Cancel</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Every file indexed from your configured sources — git repositories,
|
||||
|
||||
Reference in New Issue
Block a user