/** * 轮询状态机(Task 95)。 * * 收敛轮询 UI 的深色主题无关状态语义:错误提示(error)、重试(retry)、 * 终态刷新(markRefreshed)的有界状态转换。 * * 状态流:idle → start → polling →(fail)→ retrying →(fail…)→ failed * └→ succeed → done(终态;仅刷新历史,不重复计数) * * 有界语义: * - 重试次数有上限(maxAttempts),超过后进入 failed,不再重试; * - 终态幂等:重复 succeed 不覆盖状态、不重复计数;markRefreshed 只计一次; * - 未 start 时 fail/succeed 抛错;isTerminal 依赖抛错时调用失败但状态 * 零变更,恢复后可继续。 */ export interface PollingStateMachineOptions { /** 重试上限,必须为正整数,达到上限后失败不再重试 */ maxAttempts: number /** 判定终态,默认 SUCCESS/FAILED;抛错时调用失败且状态不变 */ isTerminal?: (status: string) => boolean } export interface PollingStateMachine { /** idle | polling | retrying | done | failed */ status: string /** 是否仍在轮询(polling 或 retrying) */ retrying: boolean attempts: number errorCount: number /** 最近一次终态状态字符串(未终态为空串) */ terminalStatus: string /** 最近一次错误消息 */ lastError: string /** 是否已刷新历史(终态后至多一次) */ refreshed: boolean refreshCount: number start: () => void /** 记录一次失败;未超上限进入 retrying,超上限进入 failed */ fail: (message: string) => void /** 记录一次成功;终态后重复调用幂等 */ succeed: (status: string) => void /** 标记终态历史已刷新;仅 done/failed 后首个调用生效 */ markRefreshed: () => void /** 已发生的重试序号列表 */ retries: () => number[] } export function createPollingStateMachine(options: PollingStateMachineOptions): PollingStateMachine { if (typeof options !== 'object' || options == null) { throw new Error('options 必须是对象') } if (!(options.maxAttempts > 0)) { throw new Error('maxAttempts 必须为正数: ' + options.maxAttempts) } if (!Number.isInteger(options.maxAttempts)) { throw new Error('maxAttempts 必须为整数: ' + options.maxAttempts) } const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED') if (typeof isTerminal !== 'function') { throw new Error('isTerminal 必须是函数') } let status = 'idle' let attempts = 0 let errorCount = 0 let terminalStatus = '' let lastError = '' let refreshCount = 0 const retrySequence: number[] = [] function start() { if (status === 'done' || status === 'failed') return status = 'polling' attempts = 0 } function fail(message: string) { if (status === 'idle') { throw new Error('未开始轮询') } if (status === 'done' || status === 'failed') return attempts += 1 errorCount += 1 lastError = message if (attempts >= options.maxAttempts) { status = 'failed' } else { retrySequence.push(attempts) status = 'retrying' } } function succeed(terminal: string) { if (status === 'idle') { throw new Error('未开始轮询') } if (status === 'done' || status === 'failed') return if (!isTerminal(terminal)) { // 非终态响应:按一次失败计入,沿用重试语义 fail('非终态响应: ' + terminal) return } attempts += 1 terminalStatus = terminal status = 'done' } function markRefreshed() { if (status !== 'done' && status !== 'failed') return if (refreshCount > 0) return refreshCount = 1 } return { get status() { return status }, get retrying() { return status === 'polling' || status === 'retrying' }, get attempts() { return attempts }, get errorCount() { return errorCount }, get terminalStatus() { return terminalStatus }, get lastError() { return lastError }, get refreshed() { return refreshCount > 0 }, get refreshCount() { return refreshCount }, start, fail, succeed, markRefreshed, retries: () => [...retrySequence], } }