# 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()` | ### Порты ```ts interface RecordingStore { write(path: string, data: ArrayBuffer): Promise; read(path: string): Promise; } interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; // все заметки vault, у которых во frontmatter есть voice-id list(): Promise<{ path: string; content: string }[]>; // атомарное чтение-изменение-запись; false, если заметки нет process(path: string, fn: (content: string) => string): Promise; } interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; } 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`, без чтения файлов, кроме найденных заметок.