8.2 KiB
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 = 5m |
schedule.ts |
due(records, now, deviceId), isStaleSent(record, now), claimable(records, now, deviceId) |
note.ts |
parseRecord(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
list(): Promise<{ path: string; content: string }[]>;
// атомарное чтение-изменение-запись; 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 по наличию voice-id.
Frontmatter правит core, одной записью
Статус и тело должны меняться атомарно, иначе обрыв между processFrontMatter и process оставит inserted без текста или текст без статуса. Поэтому core сам построчно патчит только строки voice-*: … в блоке ---, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с :/кавычками экранируются в двойных кавычках.
Выполнение отправки
tick(): защита от повторного входа (флаг running + флаг rerun) → list() → parseRecord → просроченные sent своего устройства превращаются во временную ошибку → due() → по одной: process (статус 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) в .gitignore и в git не лежат. Golden-тест при отсутствии аудиофайла MUST падать с сообщением «положите tests/fixtures/.m4a», а не пропускаться молча — иначе зелёный прогон будет ложным.
Risks / Trade-offs
- Лаг iCloud. Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято.
- Самописный патч frontmatter. Не покрывает многострочные YAML-значения в ключах
voice-*; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. - Потеря при жёстком убийстве процесса во время записи. Принято до spike периодического сброса.
list()по всему vault. На больших vault — черезmetadataCache, без чтения файлов, кроме найденных заметок.