fix(startup): retry initialization after CLI Web login

Defer one-shot background initialization when the presentation request discovers authentication is required, so preferences, LLM state, and sessions initialize only after a successful login.
This commit is contained in:
digua committed 2026-08-08 21:47:47 +08:00
1 parent 55c9e1dd25
commit b8f1c37eb2
5 files changed
+115 -6

No files matched your search

+5 -2
View File
@@ -158,6 +158,8 @@ async function initializeApp() {
await settingsStore.initLocale()
markStartupPhase('locale-settled')
},
deferAfterPresentationError: () =>
PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated,
initializeBackground: [
{
name: 'preferences',
@@ -196,6 +198,8 @@ async function initializeApp() {
],
listenForPullResults: () => apiServerStore.listenPullResult(),
})
// 401 会先切换到登录页;不要把这次未认证尝试标记为就绪,登录后由 route watcher 完整重试。
if (result.deferred) return
unlistenPullResult ??= result.stopListeningForPullResults
presentationWarning.value = result.presentationError !== null
markStartupPhase('runtime-ready')
@@ -345,8 +349,7 @@ onMounted(async () => {
const on401 = () => {
if (redirectingTo401 || router.currentRoute.value.name === 'login') return
redirectingTo401 = true
authStore.markRequiresAuth()
authStore.logout()
authStore.requireLogin()
const currentPath = router.currentRoute.value.fullPath
const redirect = currentPath.startsWith('/login') ? '/' : currentPath
router.push({ name: 'login', query: { redirect } }).finally(() => {
+45
View File
@@ -151,6 +151,7 @@ test('reveals the shell after presentation state and runs independent startup wo
])
assert.equal(result.presentationError, null)
assert.equal(result.stopListeningForPullResults, stop)
assert.equal(result.deferred, false)
releaseTasks.forEach((release) => release())
assert.deepEqual(await result.background, [])
@@ -176,5 +177,49 @@ test('uses the presentation fallback on timeout and reports background failures
assert.match(String(result.presentationError), /timed out after 5ms/)
assert.equal(applyLatePresentation, false)
assert.equal(result.deferred, false)
assert.deepEqual(await result.background, [{ name: 'sessions', error: failure }])
})
test('defers one-shot background work until authentication can recover presentation loading', async () => {
const calls: string[] = []
let authenticated = false
const initialize = () =>
initializeProgressiveAppRuntime({
initializeServices: async () => void calls.push('services'),
loadPresentation: async () => {
calls.push('presentation')
if (!authenticated) throw new Error('HTTP 401')
return { locale: 'zh-CN' }
},
applyPresentation: () => void calls.push('apply-presentation'),
applyPresentationFallback: () => void calls.push('fallback'),
deferAfterPresentationError: () => !authenticated,
initializeBackground: [
{ name: 'preferences', run: async () => void calls.push('preferences') },
{ name: 'llm', run: async () => void calls.push('llm') },
{ name: 'sessions', run: async () => void calls.push('sessions') },
],
})
const deferred = await initialize()
assert.equal(deferred.deferred, true)
assert.deepEqual(await deferred.background, [])
assert.deepEqual(calls, ['services', 'presentation'])
authenticated = true
const recovered = await initialize()
assert.equal(recovered.deferred, false)
assert.deepEqual(await recovered.background, [])
assert.deepEqual(calls, [
'services',
'presentation',
'services',
'presentation',
'apply-presentation',
'preferences',
'llm',
'sessions',
])
})
+13 -1
View File
@@ -29,6 +29,7 @@ export interface ProgressiveInitializationPorts<TPresentation> {
loadPresentation(): Promise<TPresentation>
applyPresentation(presentation: TPresentation): Promise<void> | void
applyPresentationFallback(): Promise<void> | void
deferAfterPresentationError?: (error: unknown) => boolean
initializeBackground: Array<{
name: string
run(): Promise<void>
@@ -46,6 +47,7 @@ export interface ProgressiveInitializationResult {
presentationError: unknown | null
background: Promise<BackgroundInitializationFailure[]>
stopListeningForPullResults: (() => void) | null
deferred: boolean
}
class PresentationTimeoutError extends Error {
@@ -84,6 +86,16 @@ export async function initializeProgressiveAppRuntime<TPresentation>(
await ports.applyPresentation(presentation)
} catch (error) {
presentationError = error
// 认证等可恢复门禁失败时,不能启动会写入一次性状态的后台任务;
// 调用方完成外部恢复后,应重新执行整条初始化链路。
if (ports.deferAfterPresentationError?.(error)) {
return {
presentationError,
background: Promise.resolve([]),
stopListeningForPullResults: null,
deferred: true,
}
}
await ports.applyPresentationFallback()
}
@@ -94,7 +106,7 @@ export async function initializeProgressiveAppRuntime<TPresentation>(
)
)
return { presentationError, background, stopListeningForPullResults }
return { presentationError, background, stopListeningForPullResults, deferred: false }
}
export async function initializeAppRuntime(ports: AppInitializationPorts): Promise<AppInitializationResult> {
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from './auth'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => void values.set(key, value),
}
}
test('keeps authentication required after rejected credentials and allows a later login', () => {
Object.defineProperties(globalThis, {
localStorage: { configurable: true, value: createMemoryStorage() },
sessionStorage: { configurable: true, value: createMemoryStorage() },
})
setActivePinia(createPinia())
const auth = useAuthStore()
auth.login('expired-token', false)
auth.requireLogin()
assert.equal(auth.token, '')
assert.equal(auth.isAuthenticated, false)
assert.equal(auth.requiresAuth, true)
assert.equal(sessionStorage.getItem('chatlab_auth_token'), null)
auth.login('valid-token', false)
assert.equal(auth.isAuthenticated, true)
assert.equal(auth.requiresAuth, true)
})
+13 -3
View File
@@ -43,14 +43,24 @@ export const useAuthStore = defineStore('auth', () => {
}
}
function logout() {
function clearCredentials() {
token.value = ''
rememberDevice.value = false
requiresAuth.value = false
sessionStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REMEMBER_KEY)
}
return { token, requiresAuth, rememberDevice, isAuthenticated, login, logout, markRequiresAuth }
function logout() {
clearCredentials()
requiresAuth.value = false
}
/** Clear rejected credentials while preserving the server's authentication requirement. */
function requireLogin() {
clearCredentials()
requiresAuth.value = true
}
return { token, requiresAuth, rememberDevice, isAuthenticated, login, logout, requireLogin, markRequiresAuth }
})