Invalidate account continuations across awaited project work
Why: An identity can change after an operation has passed its first response guard. Later project loads, cleanup calls, and attachment refreshes must not reopen private context or return the previous account's result to the new session. Evidence: SELF regressions failed before the guards: delayed project opening, task creation success, completed-card cleanup, and upload artifact-ID return. SELF npm test, npm run build, and git diff --check passed; full command receipt: /tmp/project-member-frontend-final-checks-20261013.json. Impact: Guard post-await continuations with the same identity epoch. Preserve the original server-side result and ignore stale client projections; do not undo the previous account's already completed work. Refs: Dev feature-project-member-access implementation plan Task 4
This commit is contained in:
+15
-5
@@ -539,9 +539,11 @@ export const store = {
|
||||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const c = await api.createConversation(pid)
|
||||
if (epoch !== identityEpoch) return
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.refreshTasks()
|
||||
if (epoch === identityEpoch) await store.openTask(c.id)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.openTask(c.id)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
|
||||
throw e
|
||||
@@ -556,8 +558,10 @@ export const store = {
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
async openTask(convId: string) {
|
||||
const epoch = identityEpoch
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
if (epoch !== identityEpoch) return
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
@@ -1196,7 +1200,7 @@ export const store = {
|
||||
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
|
||||
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
|
||||
await store.refreshArtifacts(pid)
|
||||
return up.artifactId
|
||||
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
return null
|
||||
@@ -1296,12 +1300,18 @@ export const store = {
|
||||
},
|
||||
|
||||
async tidyCards() {
|
||||
const epoch = identityEpoch
|
||||
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
|
||||
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
if (epoch !== identityEpoch) return
|
||||
try {
|
||||
const out = await api.cardDismiss(c.id)
|
||||
if (epoch !== identityEpoch) return
|
||||
store.upsertCard(out.card)
|
||||
} catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
|
||||
|
||||
@@ -249,17 +249,18 @@ describe('identity-scoped state', () => {
|
||||
expect(store.getState().artifacts).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['action', 'pin', 'dismiss', 'to-project'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
|
||||
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
|
||||
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
|
||||
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
|
||||
const pending = operation === 'action' ? store.cardAction(card, 'finish')
|
||||
: operation === 'pin' ? store.cardPin(card, true)
|
||||
: operation === 'dismiss' ? store.cardDismiss(card)
|
||||
: store.cardToProject(card)
|
||||
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
@@ -268,4 +269,57 @@ describe('identity-scoped state', () => {
|
||||
expect(store.getState().projectCards).toEqual([])
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('does not reopen a previous account conversation after its project load finishes late', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects()
|
||||
await store.openProject('another-project')
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
|
||||
const pending = store.openTask('alice-private')
|
||||
auth.beginSession()
|
||||
tasks = []
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
await pending
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
|
||||
})
|
||||
|
||||
it('does not resolve old task creation as success after a new login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
|
||||
const pending = store.createTask()
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response(task('alice-created')))
|
||||
await expect(pending).rejects.toThrow(/登录状态/)
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const started = deferred<void>()
|
||||
const previous = route
|
||||
route = (path, init) => {
|
||||
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
|
||||
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
|
||||
return previous(path, init)
|
||||
}
|
||||
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
|
||||
await started.promise
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
expect(await pending).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user