import { parseRecord, readVoiceFields, renderNote } from './note'; import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; import { NO_SPEECH, transition, type VoiceRecord } from './record'; import { due, isStaleSent, staleOwn, type Located } from './schedule'; export interface QueueDeps { recordings: RecordingStore; notes: NoteStore; transcriber: Transcriber; env: Env; } export const INTERRUPTED_SEND = 'отправка прервана'; interface Change { record: VoiceRecord; text?: string; } export class QueueRunner { private running = false; private rerun = false; private readonly controller = new AbortController(); constructor(private readonly deps: QueueDeps) {} // Повторный вызов во время работы не запускает параллельный проход, // а просит ровно один дополнительный после текущего. async tick(): Promise { if (this.running) { this.rerun = true; return; } this.running = true; try { do { this.rerun = false; await this.pass(); } while (this.rerun); } finally { this.running = false; } } dispose(): void { this.controller.abort(); } private async pass(): Promise { const { env } = this.deps; const items = await this.load(); const now = env.now(); for (const item of staleOwn(items, now, env.deviceId)) { await this.update( item.path, (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), ); } for (const item of due(items, now, env.deviceId)) await this.send(item); } private async send(item: Located): Promise { const { recordings, transcriber, env } = this.deps; const now = env.now(); // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. const sent = await this.update( item.path, (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, (r) => ({ record: transition(r, { type: 'send' }, now) }), ); if (!sent) return; let result: TranscribeResult; try { const audio = await recordings.read(sent.audio); result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); } catch (e) { result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; } if (result.ok && result.text.trim() === '') { result = { ok: false, kind: 'permanent', message: NO_SPEECH }; } const done = env.now(); const final = result; await this.update( item.path, (r) => r.id === sent.id && r.status === 'sent', (r) => final.ok ? { record: transition(r, { type: 'success' }, done), text: final.text } : { record: transition(r, { type: final.kind, message: final.message }, done) }, ); } // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. private async update( path: string, expect: (r: VoiceRecord) => boolean, change: (r: VoiceRecord) => Change, ): Promise { const box: { record: VoiceRecord | null } = { record: null }; const exists = await this.deps.notes.process(path, (content) => { const current = parseRecord(readVoiceFields(content)); if (!current || !expect(current)) return content; const next = change(current); box.record = next.record; return renderNote(content, next.record, next.text); }); return exists ? box.record : null; } private async load(): Promise { const located: Located[] = []; for (const { path, frontmatter } of await this.deps.notes.list()) { const record = parseRecord(frontmatter); if (record) located.push({ path, record }); } return located; } }