Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions frontend/taskdeck-web/src/store/captureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ export const useCaptureStore = defineStore('capture', () => {

async function fetchItems(query?: CaptureListQuery) {
const requestId = ++latestListLoadRequestId
const observedListWriteGeneration = latestListWriteGeneration
if (isDemoMode) {
loadingList.value = true
listError.value = null
Expand All @@ -164,7 +163,11 @@ export const useCaptureStore = defineStore('capture', () => {
listError.value = null
const loadedItems = await captureApi.listItems(query)
if (requestId !== latestListLoadRequestId) return
if (observedListWriteGeneration !== latestListWriteGeneration) return
// This is the explicit/user-facing list load. A successful mutation may
// finish while a scope replacement is in flight, but that must not make
// the newer scope response disappear. The request id still gives the
// usual latest-load-wins ordering; background batch polls keep the write
// generation guard in their own reader below.
items.value = loadedItems
} catch (e: unknown) {
if (requestId !== latestListLoadRequestId) return
Expand Down Expand Up @@ -439,10 +442,13 @@ export const useCaptureStore = defineStore('capture', () => {
actionBusyItemId.value = itemId
actionError.value = null
const triageResult = await captureApi.enqueueTriage(itemId, boardId)
recordCaptureWrite(itemId, true)

const existingDetail = detailById.value[itemId]
const existingSummary = items.value.find((item) => item.id === itemId)
// An uncached item has no summary to protect. Avoid invalidating an
// explicit list load solely because the detail generation advanced.
const syncSummary = Boolean(existingDetail || existingSummary)
recordCaptureWrite(itemId, syncSummary)
let optimisticDetail: CaptureItem | null = null
if (existingDetail) {
optimisticDetail = {
Expand Down Expand Up @@ -699,6 +705,12 @@ export const useCaptureStore = defineStore('capture', () => {
throw e
}

// Record every successful batch write before any reconciliation read. An
// older detail poll must not restore the pre-batch status/disposition.
for (const item of result.results) {
if (item.success) recordCaptureWrite(item.itemId, true)
}

if (result.succeeded > 0) {
toast.success(`${result.succeeded} of ${result.total} items processed`)
}
Expand Down
111 changes: 111 additions & 0 deletions frontend/taskdeck-web/src/tests/store/captureStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,43 @@ describe('captureStore', () => {
}
})

it('commits an explicit list load after a concurrent keep write', async () => {
const store = useCaptureStore()
const createdAt = new Date().toISOString()
store.items = [{
id: 'board-a-item', userId: 'u1', boardId: 'board-a', status: 'New', source: 'Typed',
textExcerpt: 'board A', createdAt, processedAt: null,
}]

let resolveKeep!: (value: unknown) => void
let resolveList!: (value: unknown[]) => void
vi.mocked(captureApi.keepItem).mockReturnValueOnce(
new Promise((resolve) => { resolveKeep = resolve }) as never,
)
vi.mocked(captureApi.listItems).mockReturnValueOnce(
new Promise((resolve) => { resolveList = resolve }) as never,
)

const keep = store.keepItem('board-a-item')
const scopedLoad = store.fetchItems({ boardId: 'board-b' })

resolveKeep({
id: 'board-a-item', userId: 'u1', boardId: 'board-a', status: 'New', source: 'Typed',
textExcerpt: 'board A', rawText: 'board A', createdAt, processedAt: null, retryCount: 0,
disposition: { kind: 'Kept', at: createdAt, byUserId: 'u1', boardId: 'board-a' },
})
await keep

resolveList([{
id: 'board-b-item', userId: 'u1', boardId: 'board-b', status: 'New', source: 'Typed',
textExcerpt: 'board B', createdAt, processedAt: null,
}])
await scopedLoad

expect(store.items.map((item) => item.id)).toEqual(['board-b-item'])
expect(store.detailById['board-a-item']?.disposition?.kind).toBe('Kept')
})

it('keeps a successful suggestion update when an older detail read lands later', async () => {
const store = useCaptureStore()
const createdAt = new Date().toISOString()
Expand Down Expand Up @@ -1519,6 +1556,80 @@ describe('captureStore', () => {
vi.useRealTimers()
}
})

it('does not invalidate an explicit list load for an uncached triage item', async () => {
const store = useCaptureStore()
let resolveList!: (value: unknown[]) => void
vi.mocked(captureApi.listItems).mockReturnValueOnce(
new Promise((resolve) => { resolveList = resolve }) as never,
)
vi.mocked(captureApi.enqueueTriage).mockResolvedValue({
status: 'Triaging', alreadyTriaging: false,
} as never)
vi.mocked(captureApi.getItem).mockRejectedValue(new Error('detail unavailable'))

const listLoad = store.fetchItems({ limit: 200 })
await store.triageItem('uncached-capture')
resolveList([{
id: 'fresh-capture', userId: 'u1', boardId: null, status: 'New', source: 'Typed',
textExcerpt: 'fresh list row', createdAt: new Date().toISOString(), processedAt: null,
}])
await listLoad

expect(store.items.map((item) => item.id)).toEqual(['fresh-capture'])
})

it('does not let a pre-batch detail poll restore the old status', async () => {
vi.useFakeTimers()
try {
const store = useCaptureStore()
const createdAt = new Date().toISOString()
const staleDetail = {
id: 'batch-capture', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed',
textExcerpt: 'batch this', rawText: 'batch this', createdAt, processedAt: null,
retryCount: 0, provenance: null,
}
store.items = [{
id: 'batch-capture', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed',
textExcerpt: 'batch this', createdAt, processedAt: null,
}]
store.detailById['batch-capture'] = staleDetail as never

let resolvePoll!: (value: unknown) => void
vi.mocked(captureApi.getItem)
.mockReturnValueOnce(new Promise((resolve) => { resolvePoll = resolve }) as never)
.mockResolvedValue({
...staleDetail,
status: 'Ignored',
disposition: { kind: 'Ignored', at: createdAt, byUserId: 'u1', boardId: null },
} as never)
vi.mocked(captureApi.batchTriage).mockResolvedValue({
total: 1,
succeeded: 1,
failed: 0,
results: [{ itemId: 'batch-capture', success: true }],
})
vi.mocked(captureApi.listItems).mockResolvedValue([{
id: 'batch-capture', userId: 'u1', boardId: null, status: 'Ignored', source: 'Typed',
textExcerpt: 'batch this', createdAt, processedAt: createdAt, errorMessage: null,
disposition: { kind: 'Ignored', at: createdAt, byUserId: 'u1', boardId: null },
}] as never)

const stop = store.pollTriageCompletion('batch-capture')
await vi.advanceTimersByTimeAsync(2_000)
expect(captureApi.getItem).toHaveBeenCalledTimes(1)

await store.batchTriage(['batch-capture'], 'ignore')
resolvePoll(staleDetail)
await Promise.resolve()
await Promise.resolve()

expect(store.detailById['batch-capture']?.status).toBe('Ignored')
stop()
} finally {
vi.useRealTimers()
}
})
})

function degradedDetail(status: string, errorMessage: string | null) {
Expand Down
Loading