feat(core): manual retry and claim-all commands

Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-09-27 16:47:08 +04:00
parent 626d832bd1
commit 88ef2bde24
2 changed files with 131 additions and 1 deletions

View File

@ -1,7 +1,7 @@
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';
import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule';
export interface QueueDeps {
recordings: RecordingStore;
@ -47,6 +47,38 @@ export class QueueRunner {
this.controller.abort();
}
// «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись
// переходит текущему устройству (иначе чужую запись здесь не отправить).
async retry(path: string): Promise<boolean> {
const { env } = this.deps;
const changed = await this.update(
path,
(r) => r.status === 'failed' || r.status === 'retry',
(r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }),
);
if (!changed) return false;
await this.tick();
return true;
}
// «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё
// устройство, статус saved (без ожидания повтора), счётчик сохраняется.
async claimAll(): Promise<number> {
const { env } = this.deps;
const now = env.now();
let claimed = 0;
for (const item of claimable(await this.load(), now, env.deviceId)) {
const changed = await this.update(
item.path,
(r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0,
(r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }),
);
if (changed) claimed++;
}
await this.tick();
return claimed;
}
private async pass(): Promise<void> {
const { env } = this.deps;
const items = await this.load();

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { initialNote, readVoiceFields } from '../src/core/note';
import { QueueRunner } from '../src/core/queue';
import type { VoiceRecord } from '../src/core/record';
import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes';
const T0 = new Date('2026-09-27T11:15:03.000Z');
function setup(steps: Step[] = []) {
const env = new TestEnv(T0);
const notes = new MemoryNotes();
const recordings = new MemoryRecordings();
const transcriber = new ScriptedTranscriber(steps);
const runner = new QueueRunner({ recordings, notes, transcriber, env });
return { env, notes, recordings, transcriber, runner };
}
type Ctx = ReturnType<typeof setup>;
function rec(id: string, over: Partial<VoiceRecord> = {}): VoiceRecord {
return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over };
}
function seed(ctx: Ctx, record: VoiceRecord): string {
const path = `voice/inbox/${record.id}.md`;
ctx.notes.files.set(path, initialNote(record, 0));
ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer);
return path;
}
const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!);
describe('QueueRunner.retry', () => {
it('failed → sent again with attempts from zero', async () => {
const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]);
const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' }));
expect(await ctx.runner.retry(path)).toBe(true);
expect(ctx.transcriber.calls).toHaveLength(1);
expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' });
});
it('retry state: sends now without waiting', async () => {
const ctx = setup([{ ok: true, text: 'x' }]);
const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' }));
expect(await ctx.runner.retry(path)).toBe(true);
expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' });
});
it("claims another device's failed record", async () => {
const ctx = setup([{ ok: true, text: 'x' }]);
const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' }));
expect(await ctx.runner.retry(path)).toBe(true);
expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' });
});
it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => {
const ctx = setup([]);
const path = seed(ctx, rec('a', { status }));
const before = ctx.notes.files.get(path);
expect(await ctx.runner.retry(path)).toBe(false);
expect(ctx.notes.files.get(path)).toBe(before);
expect(ctx.transcriber.calls).toHaveLength(0);
});
it('missing note → false', async () => {
expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false);
});
});
describe('QueueRunner.claimAll', () => {
it("takes the phone's retry and sends it now, keeping attempts", async () => {
const ctx = setup([{ ok: true, text: 'x' }]);
const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' }));
expect(await ctx.runner.claimAll()).toBe(1);
expect(ctx.transcriber.calls).toHaveLength(1);
expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' });
});
it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => {
const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]);
const o = { device: 'iphone' };
const saved = seed(ctx, rec('a', o));
const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' }));
const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' }));
const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' }));
const done = seed(ctx, rec('e', { ...o, status: 'inserted' }));
const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' }));
const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p));
expect(await ctx.runner.claimAll()).toBe(2);
expect(fields(ctx, saved)['voice-status']).toBe('inserted');
expect(fields(ctx, stale)['voice-status']).toBe('inserted');
expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched);
});
it('nothing to claim → 0', async () => {
expect(await setup().runner.claimAll()).toBe(0);
});
});