From a4163fa5fe1b74ec14ee71dcb27011a8bfbfe9a8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:12:31 +0400 Subject: [PATCH] feat(core): voice record state machine with backoff Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/record.ts | 87 +++++++++++++++++++++++++++++++ tests/record.test.ts | 119 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/core/record.ts create mode 100644 tests/record.test.ts diff --git a/src/core/record.ts b/src/core/record.ts new file mode 100644 index 0000000..8ac67c4 --- /dev/null +++ b/src/core/record.ts @@ -0,0 +1,87 @@ +// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. +export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; +export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; + +export const MAX_ATTEMPTS = 5; +// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. +export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; +// sent старше этого считается прерванной отправкой. +export const SENT_STALE_MS = 600_000; +// Код причины в voice-error для пустого транскрипта. +export const NO_SPEECH = 'no-speech'; + +export interface VoiceRecord { + id: string; + status: Status; + device: string; + audio: string; // путь в vault, без [[ ]] + mime: string; + attempts: number; + nextAttempt?: string; // ISO UTC + sentAt?: string; // ISO UTC + error?: string; + interrupted?: boolean; +} + +export type RecordEvent = + | { type: 'send' } + | { type: 'success' } + | { type: 'temporary'; message: string } + | { type: 'permanent'; message: string } + | { type: 'retry-now'; device: string } + | { type: 'claim'; device: string }; + +export class InvalidTransition extends Error { + constructor(from: Status, event: RecordEvent['type']) { + super(`invalid transition: ${event} from ${from}`); + this.name = 'InvalidTransition'; + } +} + +type Transient = 'nextAttempt' | 'sentAt' | 'error'; + +function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { + const copy = { ...record }; + for (const key of keys) delete copy[key]; + return copy; +} + +export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { + const from = record.status; + switch (event.type) { + case 'send': + if (from !== 'saved' && from !== 'retry') break; + return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; + case 'success': + if (from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; + case 'temporary': { + if (from !== 'sent') break; + const attempts = record.attempts + 1; + const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; + if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; + const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); + return { ...next, status: 'retry', nextAttempt: at }; + } + case 'permanent': + if (from !== 'sent') break; + return { + ...without(record, 'sentAt', 'nextAttempt'), + status: 'failed', + attempts: record.attempts + 1, + error: event.message, + }; + case 'retry-now': + if (from !== 'failed' && from !== 'retry') break; + return { + ...without(record, 'nextAttempt', 'sentAt', 'error'), + status: 'saved', + attempts: 0, + device: event.device, + }; + case 'claim': + if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; + } + throw new InvalidTransition(from, event.type); +} diff --git a/tests/record.test.ts b/tests/record.test.ts new file mode 100644 index 0000000..ddc9592 --- /dev/null +++ b/tests/record.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + BACKOFF_MS, + InvalidTransition, + MAX_ATTEMPTS, + transition, + type RecordEvent, + type Status, + type VoiceRecord, +} from '../src/core/record'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const base: VoiceRecord = Object.freeze({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}); +const sent = (over: Partial = {}): VoiceRecord => + Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); + +describe('transition', () => { + it('send: saved → sent with sentAt', () => { + expect(transition(base, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + sentAt: '2026-09-27T11:15:03.000Z', + }); + }); + + it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); + expect(transition(r, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + attempts: 1, + error: 'timeout', + sentAt: NOW.toISOString(), + }); + }); + + it('success: sent → inserted, clears transient keys, keeps attempts', () => { + expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ + ...base, + status: 'inserted', + attempts: 2, + }); + }); + + it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { + const got = [0, 1, 2, 3].map((a) => + transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), + ); + expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); + expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); + expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); + expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); + }); + + it('temporary: fifth failure → failed with last message', () => { + expect( + transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), + ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); + }); + + it('permanent: sent → failed immediately', () => { + expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ + ...base, + status: 'failed', + attempts: 1, + error: '401 Unauthorized', + }); + }); + + it('retry-now: failed → saved, attempts 0, device reassigned', () => { + const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); + expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + it('retry-now: retry → saved, drops nextAttempt', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); + }); + + it('claim: retry → saved, new device, attempts kept', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ + ...base, + device: 'd2', + attempts: 2, + error: 't', + }); + }); + + it('claim: sent → saved, drops sentAt', () => { + expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + const invalid: [Status, RecordEvent][] = [ + ['inserted', { type: 'send' }], + ['failed', { type: 'send' }], + ['sent', { type: 'send' }], + ['saved', { type: 'success' }], + ['retry', { type: 'success' }], + ['saved', { type: 'temporary', message: 'x' }], + ['inserted', { type: 'permanent', message: 'x' }], + ['saved', { type: 'retry-now', device: 'd1' }], + ['inserted', { type: 'retry-now', device: 'd1' }], + ['failed', { type: 'claim', device: 'd2' }], + ['inserted', { type: 'claim', device: 'd2' }], + ]; + + it.each(invalid)('rejects %s + %o', (status, event) => { + const r = Object.freeze({ ...base, status }); + expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); + }); +});