diff --git a/OUTSTANDING_TASKS.md b/OUTSTANDING_TASKS.md index ddb1ce6b1b..97889711d8 100644 --- a/OUTSTANDING_TASKS.md +++ b/OUTSTANDING_TASKS.md @@ -182,6 +182,13 @@ The maintainer directed (2026-08-30) that the repository goes **private for the ### J.2. Additional control-plane review checkpoint (2026-09-08) +PR [#3358](https://github.com/Chris0Jeky/Taskdeck/pull/3358) also needs maintainer review before +merge: its logout fix moves the board-delete expression used by mutation smoke testing, requiring +the source range and the companion assertion in `scripts/ci/smart-ci/mutation-smoke-contract.test.mjs` +to move from line 667 to 673. The expression, column bounds and negative checks are unchanged. +This small test-data change still touches a declared control path; independent review and passing +CI do not supply the maintainer decision under ADR-0066. + - [ ] **Review PR #2787 post hoc and review new CI-control candidates before merge.** The coordinator merged prompt-v3 PR #2787 at `0cebd938d79f045a20ce99bff495b986c25cf267` with hosted checks and independent Terra review, but without the maintainer review required by the ADR-0066 amendment. Its changed surface includes the Windows archive acceptance script and matching tests. The earlier SC-10 delegation covered twelve named PRs and did not include #2787. Please review that merged change; the coordinator has not inferred acknowledgement or reverted it. New nightly observation PR #2791 and the #2335 control-trust test PR must finish independent review and exact-head hosted qualification before the maintainer reviews their final heads. This checkpoint grants no release, repository-settings, or selective-execution approval. ### J.3. Twelve control-plane PRs merged outside the ADR-0066 per-PR review (2026-09-09 to 2026-09-10) diff --git a/docs/STATUS.md b/docs/STATUS.md index 354510e886..f4fc9cc264 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,26 @@ Last Updated: 2026-09-22 +## Logout retires board mutation settlements (#3306) + +Board, card (including archive/restore), label and comment operations capture the shared +session generation before transport. Logout advances it before clearing state. Old responses +still settle for their original callers but cannot repopulate caches, change counts or board +selection, invalidate new-session detail reads, publish toasts/errors, or clear new loading. +Queued label writes stop before transport across logout, including when no board detail was +loaded, and stale writes cannot initiate recovery reads under the next session. Direct card, +label, comment and provenance reads honor the same client boundary. Same-session programmatic +writes before detail loads continue to work. Deferred real-store regressions cover these cases. +Board creation callers also capture that session: board-list navigation and workspace setup +stop on retirement, including between starter-pack catalog/apply awaits. Stale continuations +cannot route the next account, start its template requests, clear its summaries or publish setup +notifications. Store results and errors still settle for the original caller. + +This prevents old-account data from reappearing and reduces cleanup after account switching. +It preserves review-first proposal behavior and does not undo server writes. Same-session +loading arbitration remains #3305; card/comment ordering PRs #3312/#3304 require separate +reconciliation with these guards before integration. + ## Column writes follow their board visit (#3314) Create, update, delete and reorder share one mutation lane per board, preserving intent order @@ -16,7 +36,7 @@ for that recovery and keeps its later result. This reduces navigation-induced board maintenance while preserving existing review-first proposal behavior. It does not cancel a write already accepted by the server. Shared ownership -for card, comment, label and board mutations remains tracked in #3306; shared loading arbitration +for card, comment, label and board mutations is covered by #3306 above; shared loading arbitration remains tracked in #3305. Deferred-response store tests and BoardView lifecycle tests cover the route/session boundary, and the existing three ordering assertions now compare actual reactive array identities as well as full contents. diff --git a/frontend/taskdeck-web/src/components/workspace/WorkspaceSetupModal.vue b/frontend/taskdeck-web/src/components/workspace/WorkspaceSetupModal.vue index acdc20b3c0..9fb802fe6f 100644 --- a/frontend/taskdeck-web/src/components/workspace/WorkspaceSetupModal.vue +++ b/frontend/taskdeck-web/src/components/workspace/WorkspaceSetupModal.vue @@ -48,8 +48,13 @@ function closeModal() { emit('close') } -async function applyStarterPack(boardId: string, starterPackId: string): Promise { +async function applyStarterPack( + boardId: string, + starterPackId: string, + isCurrentSession: () => boolean, +): Promise { const catalog = await starterPacksApi.getCatalog(boardId) + if (!isCurrentSession()) return const selectedPack = catalog.find((entry) => entry.id === starterPackId) if (!selectedPack) { throw new Error('The selected starter pack is no longer available.') @@ -59,6 +64,7 @@ async function applyStarterPack(boardId: string, starterPackId: string): Promise manifest: selectedPack.manifest, dryRun: false, }) + if (!isCurrentSession()) return if (result.hasBlockingConflicts || !result.applied) { throw new Error('The starter pack could not be applied to the new board.') @@ -88,21 +94,25 @@ async function submitSetup() { return } + const isCurrentSession = boardStore.captureSession() submitting.value = true setupError.value = null const nextBoardName = boardName.value.trim() try { const board = await boardStore.createBoard({ name: nextBoardName }) + if (!isCurrentSession()) return if (selectedSetup.value.starterPackId) { try { - await applyStarterPack(board.id, selectedSetup.value.starterPackId) + await applyStarterPack(board.id, selectedSetup.value.starterPackId, isCurrentSession) } catch (error: unknown) { + if (!isCurrentSession()) return const message = getErrorMessage(error, 'Board created, but the starter pack could not be applied') toast.warning(`${message}. You can still finish setup from the board view.`) } } + if (!isCurrentSession()) return workspace.clearHomeSummary() workspace.clearTodaySummary() @@ -111,9 +121,9 @@ async function submitSetup() { void router.push(`/workspace/boards/${board.id}`) resetState() } catch (error: unknown) { - setupError.value = getErrorMessage(error, 'Failed to create the board') + if (isCurrentSession()) setupError.value = getErrorMessage(error, 'Failed to create the board') } finally { - submitting.value = false + if (isCurrentSession()) submitting.value = false } } diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 1f1983a911..c1705f0932 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -10,7 +10,7 @@ import { buildDemoBoardList } from '../../utils/demoData' import { applyBoardCardCounts } from '../../utils/boardCardCounts' import type { CreateBoardDto, UpdateBoardDto } from '../../types/board' import { initialCardFilters, type BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' // Minimum gap between board-list fetches. Multiple views (BoardsListView, // ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick @@ -594,27 +594,31 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) async function createBoard(board: CreateBoardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const newBoard = await boardsApi.createBoard(board) + if (!isCurrentSession()) return newBoard state.boards.value.push(newBoard) helpers.toast.success(`Board "${newBoard.name}" created successfully`) return newBoard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function updateBoard(boardId: string, board: UpdateBoardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const updatedBoard = await boardsApi.updateBoard(boardId, board) + if (!isCurrentSession()) return updatedBoard // Board settings are part of the board-detail fan-out, so a detail read // that captured the pre-save state must not replace this update (#2435). helpers.markBoardDetailMutation(boardId) @@ -633,19 +637,21 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) helpers.toast.success('Board updated successfully') return updatedBoard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteBoard(boardId: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null await boardsApi.deleteBoard(boardId) + if (!isCurrentSession()) return // Clear detailed state for the current board before removing it from the // main boards list. This prevents any watchers on the `boards` array @@ -673,10 +679,10 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) helpers.toast.success('Board archived successfully') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to archive board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to archive board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -691,8 +697,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) * without ending the session, and clearing on them would drop the board the * user is looking at. * - * Two generations are bumped rather than one because the list and the detail - * read are separate lifecycles. A bumped generation is what makes an + * The shared session generation retires mutations and direct cache reads, + * including requests begun before any board detail loaded. List and detail + * reads additionally retain their separate request generations. A bump makes an * already-issued request safe: the response still arrives, finds its * generation stale, and returns without writing board state, the loading * flag, a throttle stamp, or an error surface. The loading flag is in that @@ -702,9 +709,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) * in flight during the reset would land afterwards and repopulate the store * with the previous account's boards. * - * The generation bump makes a late response harmless; the abort keeps it from - * being sent at all, so no request outlives the session that started it. Both - * lifecycles are aborted: every open list read and the active detail read. + * The generation bump suppresses late client effects. Both read lifecycles + * also receive cancellation: every open list read and the active detail read. + * Submitted writes can still complete on the server; reset does not undo them. */ function resetForLogout() { state.boardMutationSessionGeneration.value++ diff --git a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts index 40ddd94aa5..7a0bf651eb 100644 --- a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts +++ b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts @@ -13,6 +13,12 @@ import type { BoardState } from './boardState' // thing to a reader, so both map to the same copy. const TIMEOUT_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT']) +/** A null board can mean pre-load or logout; only the reset generation distinguishes them. */ +export function captureBoardSession(state: BoardState): () => boolean { + const generation = state.boardMutationSessionGeneration.value + return () => state.boardMutationSessionGeneration.value === generation +} + /** * Whether this failure is a client-side timeout — a routine outcome on every * board read since #2685 bounded them (`timeout: BOARD_REQUEST_TIMEOUT_MS`, diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index e252bfb7d7..65618d8cc0 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -4,7 +4,7 @@ import { cardCommentsApi } from '../../api/cardCommentsApi' import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { function getCardComments(cardId: string): CardComment[] { @@ -13,25 +13,29 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function fetchCardComments(boardId: string, cardId: string) { if (helpers.isDemoMode) return [] + const isCurrentSession = captureBoardSession(state) try { const comments = await cardCommentsApi.getComments(boardId, cardId) + if (!isCurrentSession()) return comments state.cardCommentsByCardId.value = { ...state.cardCommentsByCardId.value, [cardId]: comments, } return comments } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card comments') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch card comments') throw e } } async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) + if (!isCurrentSession()) return createdComment const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] state.cardCommentsByCardId.value = { ...state.cardCommentsByCardId.value, @@ -44,10 +48,10 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper helpers.toast.success('Comment added') return createdComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -58,10 +62,12 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper dto: UpdateCardCommentDto, ) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) + if (!isCurrentSession()) return updatedComment const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] state.cardCommentsByCardId.value = { ...state.cardCommentsByCardId.value, @@ -73,19 +79,21 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper helpers.toast.success('Comment updated') return updatedComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null await cardCommentsApi.deleteComment(boardId, cardId, commentId) + if (!isCurrentSession()) return const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] state.cardCommentsByCardId.value = { ...state.cardCommentsByCardId.value, @@ -93,10 +101,10 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper } helpers.toast.success('Comment deleted') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } diff --git a/frontend/taskdeck-web/src/store/board/cardStore.ts b/frontend/taskdeck-web/src/store/board/cardStore.ts index 4e43f6fb14..c0da2f4268 100644 --- a/frontend/taskdeck-web/src/store/board/cardStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardStore.ts @@ -5,7 +5,7 @@ import { cardsApi } from '../../api/cardsApi' import { getErrorMessage } from '../../utils/errorMessage' import type { CardDetachPreview, CreateCardDto, UpdateCardDto, CardCaptureProvenance } from '../../types/board' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' import type { BoardFetchOptions } from './boardCrudStore' export function createCardActions( @@ -27,9 +27,11 @@ export function createCardActions( } async function setCardArchived(boardId: string, cardId: string, archive: boolean, expectedUpdatedAt: string, expectedChildrenFingerprint?: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) const updated = expectedChildrenFingerprint === undefined ? await cardsApi.setArchived(boardId, cardId, archive, expectedUpdatedAt) : await cardsApi.setArchived(boardId, cardId, archive, expectedUpdatedAt, expectedChildrenFingerprint) + if (!isCurrentSession()) return updated helpers.markBoardDetailMutation(boardId) if (state.currentBoard.value?.id === boardId) { const existed = state.currentBoardCards.value.some(card => card.id === cardId) @@ -46,8 +48,11 @@ export function createCardActions( filters?: { search?: string; labelId?: string; columnId?: string }, ) { if (helpers.isDemoMode) return + const isCurrentSession = captureBoardSession(state) try { - state.currentBoardCards.value = await cardsApi.getCards(boardId, filters) + const cards = await cardsApi.getCards(boardId, filters) + if (!isCurrentSession()) return + state.currentBoardCards.value = cards // Keep column card counts in sync with the latest cards collection if (state.currentBoard.value) { @@ -61,17 +66,19 @@ export function createCardActions( }) } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch cards') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch cards') throw e } } async function createCard(boardId: string, card: CreateCardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const newCard = await cardsApi.createCard(boardId, card) + if (!isCurrentSession()) return newCard helpers.markBoardDetailMutation(boardId) // A board-detail refresh can commit the created card while this POST is // still resolving. Keep that newer snapshot intact instead of appending a @@ -88,15 +95,16 @@ export function createCardActions( helpers.toast.success(`Card "${newCard.title.trim()}" created successfully`) return newCard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function updateCard(boardId: string, cardId: string, card: UpdateCardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null @@ -106,6 +114,7 @@ export function createCardActions( expectedUpdatedAt: card.expectedUpdatedAt ?? existingCard?.updatedAt ?? null, } const updatedCard = await cardsApi.updateCard(boardId, cardId, request) + if (!isCurrentSession()) return updatedCard helpers.markBoardDetailMutation(boardId) // Update the card in the store @@ -117,6 +126,7 @@ export function createCardActions( helpers.toast.success('Card updated successfully') return updatedCard } catch (e: unknown) { + if (!isCurrentSession()) throw e if (helpers.isHttpConflict(e)) { helpers.toast.error(getErrorMessage(e, 'Failed to update card')) } else { @@ -124,17 +134,19 @@ export function createCardActions( } throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteCard(boardId: string, cardId: string, confirmation?: CardDetachPreview) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) let refreshChildren = false try { state.loading.value = true state.error.value = null await cardsApi.deleteCard(boardId, cardId, confirmation) + if (!isCurrentSession()) return helpers.markBoardDetailMutation(boardId) // A move, realtime refresh, or navigation can replace this state while the @@ -161,13 +173,13 @@ export function createCardActions( } helpers.toast.success('Card deleted successfully') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } // Finish mutation-owned loading/error writes before a refresh can outlive navigation. - if (refreshChildren) await refreshDetachedChildren(boardId) + if (isCurrentSession() && refreshChildren) await refreshDetachedChildren(boardId) } async function moveCard( @@ -177,6 +189,7 @@ export function createCardActions( targetPosition: number, ) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null @@ -188,6 +201,7 @@ export function createCardActions( targetColumnId, targetPosition, }) + if (!isCurrentSession()) return updatedCard helpers.markBoardDetailMutation(boardId) // The board can change while the move is in flight. Committing to another @@ -219,10 +233,10 @@ export function createCardActions( helpers.toast.success('Card moved successfully') return updatedCard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to move card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to move card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -231,12 +245,13 @@ export function createCardActions( cardId: string, ): Promise { if (helpers.isDemoMode) return null + const isCurrentSession = captureBoardSession(state) try { // cardsApi.getCardProvenance already returns null for 404 (manual cards have no // capture provenance — absence is expected, not exceptional). return await cardsApi.getCardProvenance(boardId, cardId) } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card provenance') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch card provenance') throw e } } diff --git a/frontend/taskdeck-web/src/store/board/index.ts b/frontend/taskdeck-web/src/store/board/index.ts index e7b03a0137..32382034e3 100644 --- a/frontend/taskdeck-web/src/store/board/index.ts +++ b/frontend/taskdeck-web/src/store/board/index.ts @@ -1,6 +1,6 @@ export { createBoardState } from './boardState' export type { CardFilters, BoardState } from './boardState' -export { createBoardHelpers } from './boardStoreHelpers' +export { captureBoardSession, createBoardHelpers } from './boardStoreHelpers' export type { BoardHelpers } from './boardStoreHelpers' export { createBoardCrudActions } from './boardCrudStore' export type { diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index 7c3ddec989..4c70502714 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -10,12 +10,13 @@ import { watch } from 'vue' import { labelsApi } from '../../api/labelsApi' import type { CreateLabelDto, Label, UpdateLabelDto } from '../../types/board' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' interface LabelCacheVisit { boardId: string labels: Label[] generation: number + isCurrentSession: () => boolean } class StaleBoardVisitError extends Error { @@ -29,8 +30,8 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { // Label state is one selected-board collection. Board-detail commits replace // the array, so its identity distinguishes overlapping reads within one visit. // A separate generation observes board-id transitions synchronously: unlike - // array identity it survives a same-board detail refresh, but A→B→A and - // logout→login can never reuse the old authority. + // array identity it survives a same-board detail refresh but retires A→B→A. + // The shared session generation also retires logout before any detail loaded. const readVersionByBoardId = new Map() const mutationVersionByBoardId = new Map() const mutationTailByLabelKey = new Map>() @@ -49,6 +50,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { boardId, labels: state.currentBoardLabels.value, generation: boardVisitGeneration, + isCurrentSession: captureBoardSession(state), } } @@ -56,7 +58,8 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { const currentBoard = state.currentBoard?.value return ( (currentBoard == null || currentBoard.id === visit.boardId) && - boardVisitGeneration === visit.generation + boardVisitGeneration === visit.generation && + visit.isCurrentSession() ) } @@ -119,8 +122,8 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { } } - async function reconcileCurrentLabelsAfterStaleVisit(boardId: string) { - if (state.currentBoard?.value?.id !== boardId) return + async function reconcileCurrentLabelsAfterStaleVisit(boardId: string, originalVisit: LabelCacheVisit) { + if (!originalVisit.isCurrentSession() || state.currentBoard?.value?.id !== boardId) return const visit = captureLabelVisit(boardId) const readVersion = nextReadVersion(boardId) @@ -183,6 +186,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) + if (!visit.isCurrentSession()) return newLabel helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -195,7 +199,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { } helpers.toast.success(`Label "${newLabel.name}" created successfully`) } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } return newLabel } catch (e: unknown) { @@ -220,6 +224,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { visit, () => labelsApi.updateLabel(boardId, labelId, label), ) + if (!visit.isCurrentSession()) return updatedLabel helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -229,7 +234,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { if (index !== -1) currentLabels[index] = updatedLabel helpers.toast.success('Label updated successfully') } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } return updatedLabel @@ -255,6 +260,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { visit, () => labelsApi.deleteLabel(boardId, labelId), ) + if (!visit.isCurrentSession()) return helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -264,7 +270,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { if (index !== -1) currentLabels.splice(index, 1) helpers.toast.success('Label deleted successfully') } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } } catch (e: unknown) { if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { diff --git a/frontend/taskdeck-web/src/store/boardStore.ts b/frontend/taskdeck-web/src/store/boardStore.ts index c5ac86edb0..5ace50d786 100644 --- a/frontend/taskdeck-web/src/store/boardStore.ts +++ b/frontend/taskdeck-web/src/store/boardStore.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { createBoardState, createBoardHelpers, + captureBoardSession, createBoardCrudActions, createColumnActions, createCardActions, @@ -62,6 +63,8 @@ export const useBoardStore = defineStore('board', () => { fetchBoard, cancelBackgroundBoardFetch: boardCrud.cancelBackgroundBoardFetch, resetForLogout: boardCrud.resetForLogout, + // Callers also own post-response navigation and follow-up API requests. + captureSession: () => captureBoardSession(state), createBoard: boardCrud.createBoard, updateBoard: boardCrud.updateBoard, deleteBoard: boardCrud.deleteBoard, diff --git a/frontend/taskdeck-web/src/tests/components/WorkspaceSetupModal.spec.ts b/frontend/taskdeck-web/src/tests/components/WorkspaceSetupModal.spec.ts index 0b54c4221f..d7bab7d95c 100644 --- a/frontend/taskdeck-web/src/tests/components/WorkspaceSetupModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/WorkspaceSetupModal.spec.ts @@ -4,6 +4,7 @@ import WorkspaceSetupModal from '../../components/workspace/WorkspaceSetupModal. const mocks = vi.hoisted(() => ({ createBoard: vi.fn(), + captureSession: vi.fn(), clearHomeSummary: vi.fn(), clearTodaySummary: vi.fn(), getCatalog: vi.fn(), @@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../store/boardStore', () => ({ useBoardStore: () => ({ createBoard: mocks.createBoard, + captureSession: mocks.captureSession, }), })) @@ -52,8 +54,12 @@ async function waitForUi() { } describe('WorkspaceSetupModal', () => { + let sessionCurrent = true + beforeEach(() => { vi.clearAllMocks() + sessionCurrent = true + mocks.captureSession.mockImplementation(() => () => sessionCurrent) mocks.createBoard.mockResolvedValue({ id: 'board-1', name: 'Product Sprint', @@ -101,6 +107,68 @@ describe('WorkspaceSetupModal', () => { expect(wrapper.emitted('created')?.[0]?.[0]).toEqual({ boardId: 'board-1', templateId: 'blank-board' }) }) + it('skips starter APIs, cache clears, emits, and navigation after a retired create', async () => { + let resolveBoard: ((board: { id: string; name: string }) => void) | undefined + mocks.createBoard.mockImplementation( + () => new Promise((resolve) => { + resolveBoard = resolve + }), + ) + + const wrapper = mount(WorkspaceSetupModal, { + props: { + isOpen: true, + }, + }) + + await wrapper.get('input[placeholder="For example: Product Sprint"]').setValue('Retired Board') + await wrapper.get('input[value="engineering-sprint"]').setValue(true) + await wrapper.get('form').trigger('submit') + await waitForUi() + + sessionCurrent = false + wrapper.unmount() + resolveBoard?.({ id: 'retired-board', name: 'Retired Board' }) + await waitForUi() + + expect(mocks.getCatalog).not.toHaveBeenCalled() + expect(mocks.applyStarterPack).not.toHaveBeenCalled() + expect(mocks.clearHomeSummary).not.toHaveBeenCalled() + expect(mocks.clearTodaySummary).not.toHaveBeenCalled() + expect(mocks.push).not.toHaveBeenCalled() + expect(wrapper.emitted('created')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('does not surface a stale create rejection', async () => { + let rejectBoard: ((error: Error) => void) | undefined + mocks.createBoard.mockImplementation( + () => new Promise((_, reject) => { + rejectBoard = reject + }), + ) + + const wrapper = mount(WorkspaceSetupModal, { + props: { + isOpen: true, + }, + }) + + await wrapper.get('input[placeholder="For example: Product Sprint"]').setValue('Failed Retired Board') + await wrapper.get('form').trigger('submit') + await waitForUi() + + sessionCurrent = false + rejectBoard?.(new Error('stale create failed')) + await waitForUi() + + expect(wrapper.text()).not.toContain('stale create failed') + expect(mocks.clearHomeSummary).not.toHaveBeenCalled() + expect(mocks.clearTodaySummary).not.toHaveBeenCalled() + expect(mocks.push).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('submits from the board name Enter path and ignores duplicate form submits', async () => { const wrapper = mount(WorkspaceSetupModal, { props: { @@ -215,6 +283,79 @@ describe('WorkspaceSetupModal', () => { expect(mocks.toastSuccess).toHaveBeenCalled() }) + it('skips starter-pack apply when logout retires the catalog continuation', async () => { + let resolveCatalog: ((catalog: Array<{ id: string; title: string; manifest: object }>) => void) | undefined + mocks.getCatalog.mockImplementation( + () => new Promise((resolve) => { + resolveCatalog = resolve + }), + ) + + const wrapper = mount(WorkspaceSetupModal, { + props: { + isOpen: true, + }, + }) + + await wrapper.get('input[placeholder="For example: Product Sprint"]').setValue('Catalog Retired Board') + await wrapper.get('input[value="engineering-sprint"]').setValue(true) + await wrapper.get('form').trigger('submit') + await waitForUi() + + sessionCurrent = false + resolveCatalog?.([ + { + id: 'board-blueprint-engineering-sprint', + title: 'Board Blueprint - Engineering Sprint', + manifest: { schemaVersion: '1.0', packId: 'board-blueprint-engineering-sprint' }, + }, + ]) + await waitForUi() + + expect(mocks.applyStarterPack).not.toHaveBeenCalled() + expect(mocks.toastWarning).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(mocks.clearHomeSummary).not.toHaveBeenCalled() + expect(mocks.clearTodaySummary).not.toHaveBeenCalled() + expect(mocks.push).not.toHaveBeenCalled() + expect(wrapper.emitted('created')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + wrapper.unmount() + }) + + it('suppresses template feedback and navigation when logout retires apply', async () => { + let resolveApply: ((result: { applied: boolean; hasConflicts: boolean; hasBlockingConflicts: boolean }) => void) | undefined + mocks.applyStarterPack.mockImplementation( + () => new Promise((resolve) => { + resolveApply = resolve + }), + ) + + const wrapper = mount(WorkspaceSetupModal, { + props: { + isOpen: true, + }, + }) + + await wrapper.get('input[placeholder="For example: Product Sprint"]').setValue('Apply Retired Board') + await wrapper.get('input[value="engineering-sprint"]').setValue(true) + await wrapper.get('form').trigger('submit') + await waitForUi() + + sessionCurrent = false + resolveApply?.({ applied: true, hasConflicts: true, hasBlockingConflicts: false }) + await waitForUi() + + expect(mocks.toastWarning).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(mocks.clearHomeSummary).not.toHaveBeenCalled() + expect(mocks.clearTodaySummary).not.toHaveBeenCalled() + expect(mocks.push).not.toHaveBeenCalled() + expect(wrapper.emitted('created')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + wrapper.unmount() + }) + it('supports selecting the client onboarding setup shape', async () => { const wrapper = mount(WorkspaceSetupModal, { props: { diff --git a/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts new file mode 100644 index 0000000000..af7cc02540 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useBoardStore } from '../../../store/boardStore' +import { useToastStore } from '../../../store/toastStore' +import { boardsApi } from '../../../api/boardsApi' +import { cardsApi } from '../../../api/cardsApi' +import { labelsApi } from '../../../api/labelsApi' +import { cardCommentsApi } from '../../../api/cardCommentsApi' +import type { BoardDetail, Card, Label } from '../../../types/board' +import type { CardComment } from '../../../types/comments' + +vi.mock('../../../api/boardsApi') +vi.mock('../../../api/cardsApi') +vi.mock('../../../api/labelsApi') +vi.mock('../../../api/cardCommentsApi') + +const time = '2026-09-22T12:00:00Z' +const board: BoardDetail = { + id: 'board', name: 'Account A board', description: null, isArchived: false, + createdAt: time, updatedAt: time, + columns: [{ id: 'column', boardId: 'board', name: 'Todo', position: 0, + wipLimit: null, cardCount: 1, createdAt: time, updatedAt: time }], +} +const card: Card = { + id: 'card', boardId: 'board', columnId: 'column', title: 'Account A card', + description: '', dueDate: null, isBlocked: false, blockReason: null, + position: 0, labels: [], createdAt: time, updatedAt: time, +} +const label: Label = { + id: 'label', boardId: 'board', name: 'Account A label', colorHex: '#123456', + createdAt: time, updatedAt: time, +} +const comment: CardComment = { + id: 'comment', boardId: 'board', cardId: 'card', parentCommentId: null, + authorUserId: 'account-a', authorUsername: 'account-a', content: 'Account A comment', + isDeleted: false, editedAt: null, mentions: [], createdAt: time, updatedAt: time, +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +type Store = ReturnType +const operations = [ + { name: 'create board', api: boardsApi.createBoard, result: board, + start: (s: Store) => s.createBoard({ name: 'Account A board' }) }, + { name: 'update board', api: boardsApi.updateBoard, result: board, + start: (s: Store) => s.updateBoard('board', { name: 'Account A board' }) }, + { name: 'delete board', api: boardsApi.deleteBoard, result: undefined, + start: (s: Store) => s.deleteBoard('board') }, + { name: 'create card', api: cardsApi.createCard, result: card, + start: (s: Store) => s.createCard('board', { columnId: 'column', title: card.title }) }, + { name: 'update card', api: cardsApi.updateCard, result: card, + start: (s: Store) => s.updateCard('board', 'card', { title: card.title }) }, + { name: 'delete card', api: cardsApi.deleteCard, result: undefined, + start: (s: Store) => s.deleteCard('board', 'card') }, + { name: 'move card', api: cardsApi.moveCard, result: card, + start: (s: Store) => s.moveCard('board', 'card', 'column', 0) }, + { name: 'archive card', api: cardsApi.setArchived, result: card, + start: (s: Store) => s.setCardArchived('board', 'card', true, time) }, + { name: 'restore card', api: cardsApi.setArchived, result: card, + start: (s: Store) => s.setCardArchived('board', 'card', false, time) }, + { name: 'create label', api: labelsApi.createLabel, result: label, + start: (s: Store) => s.createLabel('board', { name: label.name, colorHex: label.colorHex }) }, + { name: 'update label', api: labelsApi.updateLabel, result: label, + start: (s: Store) => s.updateLabel('board', 'label', { name: label.name }) }, + { name: 'delete label', api: labelsApi.deleteLabel, result: undefined, + start: (s: Store) => s.deleteLabel('board', 'label') }, + { name: 'create comment', api: cardCommentsApi.createComment, result: comment, + start: (s: Store) => s.createCardComment('board', 'card', { content: comment.content }) }, + { name: 'update comment', api: cardCommentsApi.updateComment, result: comment, + start: (s: Store) => s.updateCardComment('board', 'card', 'comment', { content: comment.content }) }, + { name: 'delete comment', api: cardCommentsApi.deleteComment, result: undefined, + start: (s: Store) => s.deleteCardComment('board', 'card', 'comment') }, +] + +function installNextSession(s: Store) { + s.boards = [{ ...board, name: 'Account B board' }] + s.currentBoard = structuredClone({ ...board, name: 'Account B board' }) + s.activeBoardId = 'board' + // Keep the same resource IDs, including a child that would trigger a recovery GET. + s.currentBoardCards = [{ ...card, title: 'Account B card' }, + { ...card, id: 'child', parentCardId: 'card', title: 'Account B child' }] + s.currentBoardLabels = [{ ...label, name: 'Account B label' }] + s.cardCommentsByCardId = { card: [{ ...comment, content: 'Account B comment' }] } + s.loading = true + s.error = 'Account B error' +} + +function snapshot(s: Store) { + return JSON.parse(JSON.stringify({ boards: s.boards, currentBoard: s.currentBoard, + activeBoardId: s.activeBoardId, cards: s.currentBoardCards, labels: s.currentBoardLabels, + comments: s.cardCommentsByCardId, loading: s.loading, error: s.error })) +} + +describe('board mutation session ownership', () => { + beforeEach(() => { vi.resetAllMocks(); setActivePinia(createPinia()) }) + + it('retires caller ownership synchronously through the same logout boundary', () => { + const s = useBoardStore() + const ownsOriginalSession = s.captureSession() + expect(ownsOriginalSession()).toBe(true) + expect(s.currentBoard).toBeNull() + s.resetForLogout() + expect(ownsOriginalSession()).toBe(false) + expect(s.captureSession()()).toBe(true) + }) + + for (const op of operations) { + for (const replacement of ['logged out', 'next account'] as const) { + it(`${op.name}: late success cannot change ${replacement} state`, async () => { + const s = useBoardStore() + const toast = useToastStore() + const success = vi.spyOn(toast, 'success') + const warning = vi.spyOn(toast, 'warning') + const pending = deferred() + vi.mocked(op.api).mockReturnValueOnce(pending.promise as never) + // No committed detail: logout must invalidate even null → null transitions. + const call = op.start(s) + s.resetForLogout() + if (replacement === 'next account') installNextSession(s) + const before = snapshot(s) + const refs = [s.boards, s.currentBoard, s.currentBoardCards, s.currentBoardLabels, s.cardCommentsByCardId] + pending.resolve(op.result) + await expect(call).resolves.toEqual(op.result) + expect(snapshot(s)).toEqual(before) + expect([s.boards, s.currentBoard, s.currentBoardCards, s.currentBoardLabels, s.cardCommentsByCardId]) + .toEqual(refs) + refs.forEach((ref, index) => expect([s.boards, s.currentBoard, s.currentBoardCards, + s.currentBoardLabels, s.cardCommentsByCardId][index]).toBe(ref)) + expect(success).not.toHaveBeenCalled() + expect(warning).not.toHaveBeenCalled() + expect(boardsApi.getBoard).not.toHaveBeenCalled() + expect(labelsApi.getLabels).not.toHaveBeenCalled() + }) + } + + it(`${op.name}: late rejection reaches caller without changing next account`, async () => { + const s = useBoardStore() + const toast = useToastStore() + const errorToast = vi.spyOn(toast, 'error') + const pending = deferred() + vi.mocked(op.api).mockReturnValueOnce(pending.promise as never) + const failure = { response: { status: 409 }, message: 'Account A conflict' } + const call = op.start(s) + const rejected = expect(call).rejects.toBe(failure) + s.resetForLogout() + installNextSession(s) + const before = snapshot(s) + pending.reject(failure) + await rejected + expect(snapshot(s)).toEqual(before) + expect(errorToast).not.toHaveBeenCalled() + }) + } + + it('drops a queued label change across logout with no committed board before either session', async () => { + const s = useBoardStore() + const pending = deferred