Andrey Epifancev 97a560820f feat(core): queue runner with retries, stale-sent recovery and re-entrancy guard
Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:38:46 +04:00

124 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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<void> {
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<void> {
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<VoiceRecord | null> {
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<Located[]> {
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;
}
}