Миграция по записи «Версия 2» журнала канона: поля Дата и Источник в docs/adr/template.md жирным, объявлено место под статус (- **Статус:** заменено на ADR-… либо устарело), та же строка добавлена в «Соглашения» docs/adr/README.md, docs/.pm.json переведён на canon 2. Переносить статусы было не из чего: записей ADR в проекте пока нет, каталог несёт только README и шаблон. docs.py check зелёный, версия проекта сошлась с версией скрипта. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
healthlog
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export, складывает их в единое хранилище и отдаёт другим моим проектам через HTTP API.
Зачем
Данные о здоровье и тренировках нужны сразу трём моим приложениям: агенту-медику (анализ здоровья), трекеру (разбор тренировок) и игре (мотиватор по активности). Интегрировать каждое из них с Health Auto Export по отдельности — значит в каждом писать приём, дедупликацию и хранение заново.
healthlog делает это один раз. Телефон шлёт данные в него, все остальные проекты берут данные из него.
Границы
Это хранилище, а не аналитика. healthlog принимает, дедуплицирует, хранит и отдаёт. Он не переименовывает поля Apple и не интерпретирует значения — этим занимается тот, кто данные читает.
Одну уступку хранилище всё же делает: оно умеет свести метрику к запрошенной сетке («шаги по дням»). Иначе каждый из клиентов повторял бы одну и ту же логику выбора слоя, а ошибиться в ней легко — просуммировать не тот разрез и получить завышение втрое. Но род свёртки не проставлен вручную, а измерен сверкой слоёв между собой; где измерить не вышло, свёртка не предлагается вовсе.
Источников два: Health Auto Export (куплен, пожизненный премиум) — ежедневный поток, и родной экспорт Apple Health раз в 2–3 месяца — источник истины для нижнего слоя.
Как устроено
экспорт Apple ────────┐ снапшот всей истории, раз в 2–3 месяца
▼
iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz)
│ │
│ └── события поверх снапшота
▼
SQLite ──┬──► HTTP read API ──► мои приложения
(свёртка по │
журналу) └──► MCP ───────────► агенты
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается
свёрткой import(экспорт) + replay(доставки). Отсюда и главный инвариант —
точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть
журналом.
Подробности — docs/architecture.md.
Состояние
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
пакеты, складывает их в сырой архив и разбирает метрики в часовые объекты
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим id (workouts, stateOfMind) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные.
Есть и пересборка: healthlog reindex проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет.
Первый маршрут чтения открыт: каталог разрезов (GET /api/v1/metrics) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает 304 по ETag — снимок витрины при этом не
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: read API точек, тренировок и записей — сами данные наружу пока не отдаются. План в docs/tasks/PLAN.md.
Разведка формата закончена: 50 находок на живом потоке, половина расходится с документацией Health Auto Export — docs/research/apple-health.md.
Команды
healthlog serve приём + read API + MCP
healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала
healthlog healthcheck проверка живости для docker HEALTHCHECK
Пересборка витрины
Разбор пишется по реальным данным и будет ошибаться. Исправленный разбор применяется к уже разобранному пересборкой:
healthlog reindex --config ./config.toml
Команда собирает витрину в отдельный файл рядом с рабочей базой и печатает два отпечатка — рабочей витрины и пересобранной. Рабочую базу она не трогает вовсе (открывает её только на чтение и без наката миграций), поэтому запускать её при живом сервисе безопасно — так и стоит делать, если нужно просто сверить.
Применить результат — другое дело. Подмена возможна только при остановленном сервисе: он держит файл базы открытым, и переименование поверх живого процесса портит базу молча. Сервис при этом надо остановить до пересборки, а не после: доставки, приехавшие за время прогона, в собранный файл не попадут, и подмена стёрла бы их учёт вместе с заголовками, которые не восстанавливаются ниоткуда. Команда это проверяет и в таком случае процедуру подмены не печатает вовсе.
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
Прогон идёт линейно по архиву: на 116 телах — около полуминуты, и время растёт вместе с архивом. Свободного места нужно не меньше текущего размера базы: собранный файл ложится рядом с ней, на тот же том.
Прогон, убитый жёстко (SIGKILL, потеря питания), оставляет рядом с базой файлы
*.partial* — это его недособранный результат. Штатное прерывание (Ctrl+C) их
убирает само; оставшиеся можно удалять руками, следующему прогону они не мешают.
Локальный запуск
Конфиг необязателен — без него берутся умолчания (:8080, ./healthlog.db,
./raw). Для своих значений скопируй config.example.toml в config.toml.
task run
Проверка:
curl localhost:8080/healthz
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
Подключение телефона по локальной сети
Сервис слушает все интерфейсы (addr = ":8080"), так что телефон в той же
сети достучится по IP машины. В Health Auto Export заводится одна
автоматизация: REST API, формат JSON, минимальная гранулярность
(«Summarize Data» выключен), URL вида http://<ip-машины>:8080/api/v1/ingest.
Если auth.write_tokens пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом write auth disabled
на старте. Для доступа снаружи понадобится и токен, и TLS — это шаг «Деплой».
Документация
- docs/passport.md — цель проекта, типовые сценарии работы, референсы: чужие проекты, у которых смотрим решения, прежде чем придумывать своё
- docs/architecture.md — устройство: принципы, компоненты, внешние границы, эксплуатация, деплой
- docs/database.md — схема хранилища и настройки с числовым значением
- docs/adr/ — почему решено именно так
- docs/conventions/ — как пишем код
- docs/security.md — периметр и модель угроз
- docs/review.md — настройка конвейера ревью и журнал дефектов
- docs/tasks/PLAN.md — цели и обоснование их порядка
- docs/tasks/BACKLOG.md — что брать следующим, включая отложенные идеи
- docs/research/apple-health.md — что показал реальный поток Health Auto Export; источник истины по формату, документация приложения местами расходится с тем, что оно шлёт