From 88ef2bde24ca6961d437938ffd9a3e095279dc97 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:47:08 +0400 Subject: [PATCH] feat(core): manual retry and claim-all commands Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/queue.ts | 34 ++++++++++++- tests/queue-commands.test.ts | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/queue-commands.test.ts diff --git a/src/core/queue.ts b/src/core/queue.ts index d536c78..d69d1ec 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -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 { + 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 { + 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 { const { env } = this.deps; const items = await this.load(); diff --git a/tests/queue-commands.test.ts b/tests/queue-commands.test.ts new file mode 100644 index 0000000..8d6645a --- /dev/null +++ b/tests/queue-commands.test.ts @@ -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; + +function rec(id: string, over: Partial = {}): 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); + }); +});