Andrey Epifancev e00d7e182c docs(openspec): core-recording-lifecycle proposal, specs, design
Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:44:26 +04:00

8.2 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 = 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, без чтения файлов, кроме найденных заметок.