Andrey Epifancev 1cd72201e2 test: track test-01.m4a fixture so golden tests pass on a clean clone
Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:08:33 +04:00

18 KiB
Raw Blame History

Design: core-recording-lifecycle

Context

Репозиторий пуст (каркас OpenSpec, одна фикстура test-01). Ограничения из CLAUDE.md: mobile-first (iPhone), isDesktopOnly: false, никакого Node API в рантайме, src/core/ без импортов obsidian, core покрыт vitest полностью, src/obsidian/ — тонкая обёртка без юнит-тестов. Vault синхронизируется через iCloud между маком и iPhone, плагин стоит на обоих.

Goals / Non-Goals

Goals:

  • Запись не теряется: аудио и заметка сохраняются до любой сети.
  • Вся логика состояний, повторов, устройств и формата заметки — в core и под тестами.
  • Обёртка сводится к реализации четырёх портов и вызову tick() по событиям.

Non-Goals:

  • Реальный провайдер, HTTP, коды ответа, переключение провайдеров (issue провайдера).
  • Команды, настройки, UI, MediaRecorder, реализация портов на app.vault (issue обёртки).
  • Периодический сброс записи на диск во время записи (отдельный spike на реальном iPhone: неизвестно, читается ли частичный mp4 из Safari MediaRecorder).
  • Конвертация форматов, удаление аудио, daily notes.

Decisions

Подход: чистое ядро + оркестратор на портах

Машина состояний, планировщик и формат заметки — чистые функции. Поверх них асинхронный QueueRunner в core работает через порты. Отвергнуто: (A) полностью чистый редьюсер с эффектами — оркестрация уехала бы в нетестируемую обёртку; (C) XState — лишняя зависимость и вес бандла для ~8 состояний.

Модули src/core/

Модуль Ответственность
record.ts тип VoiceRecord, Status, transition(record, event, now), MAX_ATTEMPTS = 5, BACKOFF_MS = [30s, 2m, 10m, 30m], SENT_STALE_MS = 10m, NO_SPEECH = 'no-speech'
schedule.ts due(records, now, deviceId), isStaleSent(record, now), claimable(records, now, deviceId)
note.ts parseRecord(fields), readVoiceFields(content), renderNote(content, record, text?), построчный патч ключей voice-* во frontmatter, initialNote(record)
naming.ts makeId(date, random), extFromMime, audioPath, notePath(date, n)
ports.ts интерфейсы портов (ниже)
capture.ts saveCapture(ports, capture)
queue.ts QueueRunner с tick(), retry(notePath), claimAll()

Порты

interface RecordingStore {
  write(path: string, data: ArrayBuffer): Promise<void>;
  read(path: string): Promise<ArrayBuffer>;
}
interface NoteStore {
  exists(path: string): Promise<boolean>;
  create(path: string, content: string): Promise<void>;
  // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
  // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
  // содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
  // в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
  // состояние внутри process().
  list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
  // атомарное чтение-изменение-запись; false, если заметки нет
  process(path: string, fn: (content: string) => string): Promise<boolean>;
}
interface Transcriber {
  transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult>;
}
type TranscribeResult =
  | { ok: true; text: string }
  | { ok: false; kind: 'temporary' | 'permanent'; message: string };
interface Env { now(): Date; random(): number; deviceId: string; }

Env объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. NoteStore.process в обёртке реализуется через vault.process. list() — только через metadataCache.getFileCache(file)?.frontmatter по наличию voice-id; поэтому порт отдаёт frontmatter-объект, а не текст заметки.

Frontmatter правит core, одной записью

Статус и тело должны меняться атомарно, иначе обрыв между processFrontMatter и process оставит inserted без текста или текст без статуса. Поэтому core сам построчно патчит только строки voice-*: … в блоке ---, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с :/кавычками экранируются в двойных кавычках.

Выполнение отправки

tick(): защита от повторного входа (флаг running + флаг rerun) → list() → parseRecord(frontmatter) → просроченные sent своего устройства превращаются во временную ошибку → due() → по одной: process (внутри функция заново читает voice-* из актуального content через readVoiceFields; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус sent, voice-sent-at) → read аудио → transcribe → process (inserted + текст или retry/failed + область). Если process вернул false — запись отменена, идём дальше. Исключение из transcribe или read трактуется как временная ошибка с его сообщением.

Ручные команды

claimAll(): для каждой записи из claimable() одной записью voice-device := текущее, voice-status := saved, удалить voice-next-attempt и voice-sent-at; voice-attempts сохраняется. Затем tick(). retry(notePath): только из failed/retry → saved, voice-attempts := 0, удалить voice-next-attempt/voice-error; затем tick().

Захват

saveCapture({ chunks: ArrayBuffer[], mime, startedAt, endedAt, interrupted }): суммарно 0 байт → null; иначе склейка → write аудио → подбор свободного имени заметки через exists → create с initialNote → возвращает путь. MediaRecorder и детекция обрыва — в обёртке.

Тестовая инфраструктура (Task 1)

Шаблон obsidian-sample-plugin (esbuild, manifest.json с isDesktopOnly: false), vitest. Тест-страж сканирует src/core/**/*.ts и падает на импорт obsidian, fs, path, child_process, node:*. Фейки в tests/fakes/: in-memory RecordingStore/NoteStore, скриптуемый Transcriber, Env с управляемыми часами.

Аудиофикстуры *.m4a лежат в git (test-01.m4a ~85 КБ), так что npm test зелёный на чистом клоне. Если для эталона <name>.note.md нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча.

Risks / Trade-offs

  • Лаг iCloud. Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято.
  • Самописный патч frontmatter. Не покрывает многострочные YAML-значения в ключах voice-*; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами.
  • Потеря при жёстком убийстве процесса во время записи. Принято до spike периодического сброса.
  • list() по всему vault. Дёшево только при реализации через metadataCache (закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, через process.
  • Отставание metadataCache. После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутри process по актуальному содержимому.

Decisions made without discussion

Формат: дата — решение. Обоснование. Цена ошибки. Пометка «пересмотрено» — решение скорректировано человеком на ревью.

  1. 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в failed с причиной no-speech; в заметке > [!info] В записи не распознано речи, без слова «ошибка» и без призыва повторить. Аудио остаётся. Повтор тишины бесполезен; это свойство записи, а не сбой сервиса (формулировка пересмотрена на ревью). Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет.
  2. 2026-09-27 — Очередь ищет записи по voice-id во всём vault, а не только в voice/inbox/. Пользователь может перенести заметку до распознавания. Пересмотрено: NoteStore.list() обязан работать через metadataCache без чтения файлов, это записано в контракт порта. Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо.
  3. 2026-09-27 — Добавлены ключи voice-mime (исходный mime с параметрами) и voice-sent-at (ISO UTC). Провайдеру нужен точный mime, а не восстановленный по расширению; зависший sent нужно отличать от идущего. Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечный sent.
  4. 2026-09-27 — Порог зависшего sent — 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только через claimAll. Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут. Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут.
  5. 2026-09-27 — claimAll сохраняет voice-attempts, меняет только устройство, статус (в saved) и удаляет voice-next-attempt/voice-sent-at. «Повторить распознавание» работает для failed и retry и сбрасывает счётчик в 0. claimAll — про «где», а не «заново»; ручной повтор — осознанный перезапуск. Если claimAll должен был давать 5 свежих попыток — запись с телефона уйдёт в failed раньше; исправляется ручным повтором.
  6. 2026-09-27 — Неизвестный mime сохраняется с расширением .bin, запись не отвергается. Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер. Файл .bin не проигрывается встроенным плеером Obsidian; аудио при этом не потеряно.
  7. 2026-09-27 — При переходе в inserted удаляются voice-next-attempt, voice-sent-at, voice-error; voice-attempts остаётся. Итоговая заметка чище; счётчик полезен как история. Косметика.
  8. 2026-09-27 — Frontmatter правит сам core построчно, только ключи voice-*, без YAML-библиотеки; статус и тело меняются одной операцией process. Два вызова (processFrontMatter + process) не атомарны: обрыв между ними даёт inserted без текста. Самописный патч может сломаться на экзотическом YAML в ключах voice-* — плагин такой не пишет; сохранность чужих ключей закрыта тестами.
  9. 2026-09-27 — Golden-сравнение побайтное при фиксированном Env (время, случайность, устройство). Нет аудиофикстуры для эталона — тест падает с понятным сообщением, а не пропускается. Пересмотрено на ревью: tests/fixtures/*.m4a убран из .gitignore, test-01.m4a закоммичен, чтобы на чистом клоне тесты были зелёными. Молчаливый skip дал бы ложно-зелёный прогон. Нулевая: фикстура в репозитории; падение возможно только если добавить эталон без аудио — это и должно быть ошибкой.
  10. 2026-09-27 — В .gitignore добавлен .claude/worktrees/. Хук guard-main запретил правку .git/info/exclude в main; вложенные worktree иначе видны как неотслеживаемые. Нулевая.
  11. 2026-09-27 — voice-id строится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следует voice-id, т.е. тоже UTC. Порядок id не зависит от пояса устройства; имя заметки — для человека. Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкой voice-audio.
  12. 2026-09-27 — NoteStore.list() отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутри process по актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. Следствие правки 2: без чтения файлов текста нет, а metadataCache обновляется асинхронно. Без перепроверки — повторная отправка только что отправленной записи.
  13. 2026-09-27 — Исключение из RecordingStore.read или Transcriber.transcribe трактуется как временная ошибка со своим сообщением. Классифицирует провайдер, но непредвиденный throw не должен ронять очередь. Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затем failed.
  14. 2026-09-27 — Причина «нет речи» хранится кодом voice-error: no-speech, а не текстом. Рендер должен отличать её от обычной ошибки без разбора сообщений. Нулевая: значение внутреннее.