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
+185 -7
View File
@@ -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");
+36
View File
@@ -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; }
+9
View File
@@ -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,