## Context `main` 7911dc7, схема v4, агент v12. Устройство, на которое опираются правки: - **Зеркало** — `mirror.apply_state` делает upsert строки `issue` (прежние значения теряются) и заменяет комментарии задачи присланными (`issue_comment`, время `created`/`updated` — строка Jira как есть, например `2026-10-22T13:05:11.000+0300`). Поле `jira_updated` (Jira `updated`) уже приходит в состоянии. - **Полный прогон** — `refresh {jql: base_jql}` (`queue.is_full_refresh`); его ставят автообновление (`queue.maybe_auto_refresh`, по умолчанию раз в 60 минут, `auto_refresh_minutes`), `POST /refresh {full}` и MCP `refresh full`. Хвост пакета — `refresh {keys}` по ключам пакета. `base_jql` вычисляется из проектов и меняется при создании проекта. - **Выдача** — `queue.deliver_params` дописывает параметры при выдаче (у `refresh` — `boards`), в базе `params` без них. `report_result` по `refresh` пишет `sync_run.finished_at` и типы досок из `result.boards`. - **Агент** (`jira-agent/src/agent.py`, v12, Python 3.9 stdlib): `do_refresh` → `search_all(jql)` с полями зеркала (включая `updated`), дочитывание соседей (`search_tolerant`, `key in (...)`), имена спринтов, отправка `POST /agent/state` пачками по 100. Неизвестные параметры команды агент игнорирует (`p.get`). - **Спринт в зеркале** — имя активного или будущего спринта доски проекта; у задач закрытых спринтов `sprint = null`. Активный и будущий в зеркале не различаются. - **MCP** — `mcp_server.py` ходит только через `client`; `issue` форматирует `GET /issues/{key}` (комментарии — 5 последних). - **Время** — в базе ISO-8601 UTC (`db.now()`), кроме дат без времени и времени комментариев. ## Goals / Non-Goals **Goals:** журнал изменений задач из Jira в хабе; лента событий за период в API и MCP; история задачи в `GET /issues/{key}` и MCP `issue`; стартовая загрузка по незакрытым задачам текущего спринта; регулярная загрузка только по изменившимся задачам; ни одно изменение не теряется молча. **Non-Goals и Anti-Goals:** см. proposal.md. ## Decisions 1. **Таблица `issue_change`, схема v5.** Колонки: `history_id` (TEXT, id записи журнала Jira), `seq` (INTEGER, порядковый номер поля в записи, с 0), `key`, `at` (ISO-8601 UTC — время записи, приведённое хабом), `created` (время Jira как есть), `author` (отображаемое имя, иначе логин, иначе `NULL`), `field` (логическое имя, см. решение 3), `field_id` (id поля Jira или `NULL`), `field_name` (имя поля Jira как есть), `from_value`, `from_string`, `to_value`, `to_string` (как есть, могут быть `NULL`), `fetched_at`. PK `(history_id, seq)`; индексы `(at)` и `(key, at)`. Запись — `INSERT OR IGNORE`: повтор записи журнала ничего не меняет. Пишет таблицу только приём журнала от агента; API и MCP — только читают (как зеркало). *Почему по строке на поле, а не на запись журнала:* лента фильтрует по виду поля, одна запись Jira часто меняет статус и резолюцию разом. 2. **Время комментариев в UTC.** Колонка `issue_comment.created_at` (ISO-8601 UTC, приведённое из `created`; не разобралось — `NULL`), заполняется в `apply_state`; миграция v4 → v5 заполняет её у имеющихся строк. Строка `created` остаётся как есть (её показывают интерфейсы). Время журнала приводится так же (`at`). Разбор: ISO-8601 со смещением `+0300` / `+03:00` / `Z`, с дробной частью или без; без смещения — UTC. 3. **Логическое имя поля.** Хаб вычисляет `field` при приёме: `status`, `description`, `summary`, `assignee`, `priority`, `duedate`, `labels`, `resolution` — по `field_id`, а без него — по `field_name` (встроенные поля Jira называются так же); `planned_start`, `planned_end`, `epic_link` — по `field_id`, совпавшему с `field_ids` / `epic_link.field_id` настроек; остальное — `field_name` как есть. Хаб, а не агент: соответствие логических имён и id полей уже живёт в настройках хаба, агент шлёт сырые данные. 4. **Виды событий ленты.** `status` — поле `status`; `description` — поле `description`; `dates` — поля `duedate`, `planned_start`, `planned_end`; `comment` — комментарий с `created_at` в окне (из `issue_comment`, не из журнала); `other` — прочие поля журнала. По умолчанию лента отдаёт `status`, `description`, `dates`, `comment`; `other` — только по запросу (`kinds=all` или явно). Остальные поля журнала сохраняются всегда — приходят бесплатно, понадобятся вебу. 5. **Отметка «с какого момента» — из `sync_run`, без новой настройки.** Колонка `sync_run.history_at` (время приёма итога журнала, `NULL` — журнал по прогону не принят). Хаб проставляет её только у полного прогона (`refresh` по базовой JQL), завершившегося `done` с `result.history` без `error` (решение 11). Отметка — `started_at` последнего `sync_run` с `history_at IS NOT NULL` **независимо от `query`** (новый проект не обнуляет отметку прежних). Параметр `history` при выдаче `refresh`: - полный прогон, отметка есть — `{since: <отметка − 15 минут>}` (UTC, ISO-8601 с `Z`); если `query` прогона-отметки не равна текущей `base_jql` (после него заведён проект) — ещё и `jql` стартовой загрузки (решение 6): прежние проекты дочитываются от отметки, новый получает стартовую историю; - полный прогон, отметки нет — `{jql: <стартовая>}`; - `refresh {keys}` и `refresh` по небазовой JQL — `{since}`, если отметка есть, иначе `history` не дописывается (стартовую загрузку делает только полный прогон). Запас 15 минут покрывает расхождение часов Mac mini и сервера Jira и время самого прогона; повторы отбрасываются по PK. Отметка сдвигается **только** полным прогоном с принятым журналом: прогон упал, регулярная часть журнала не дочитана, агент старый — следующий прогон возьмёт прежнюю отметку и дочитает пропущенное (анти-цель «молча теряет»). *Отвергнуто:* отдельная настройка-отметка (её правят руками и через `settings_set`, сдвиг надо делать атомарно с итогом прогона); сравнение `jira_updated` в хабе с ответом агенту списком изменившихся (хаб уже перезаписал строку, если журнал потом не дочитан — изменение потеряно); отметка по `query` = текущая `base_jql` (после нового проекта прежние проекты теряли изменения вне спринта между отметкой и прогоном — находка рецензента). 6. **Стартовая загрузка.** `history.jql = () AND sprint in openSprints() AND statusCategory != Done`. Агент тянет журнал этих задач целиком (без отсечения по времени). Решение владельца: «максимум незакрытые в текущем спринте». `openSprints()` — активные спринты, будущие не входят. Категория статуса, а не настройка `closed_statuses`: статус из настройки, которого нет в Jira, даёт 400 на всю JQL, и стартовая загрузка не прошла бы никогда. Стартовая загрузка — разовая и «по возможности»: её ошибка не мешает отметке (решение 11) и не повторяется. Проект на канбан-доске без спринтов в стартовую загрузку не попадает — его история начнётся с первого регулярного прогона. 7. **Агент v13: регулярный режим (`history.since`).** После отправки состояния (все пачки `POST /agent/state`) агент отбирает из выгруженных задач (база и соседи) те, у кого Jira `updated >= since` (сравнение моментов времени с учётом смещения), и по ним делает `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей. Из журнала каждой задачи берёт записи с `created >= since`. Изменившихся задач нет — ни одного лишнего вызова Jira. Решение владельца: без второго «слепого» запроса по `updated >= …`, отбор по уже выгруженному. 8. **Агент v13: неполный журнал.** Если у задачи `changelog.total` больше числа записей в ответе `search`, агент дочитывает журнал `GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и там неполный — передаёт что есть, пишет в лог (ключ, сколько получено из скольких) и добавляет ключ в `result.history.incomplete`. Отметку неполный журнал не держит: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда; неполнота видна в результате команды. Jira Server/DC, по ожиданию, отдаёт журнал целиком; проверить на живом агенте (план, живая проверка). Элемент, который хаб отверг бы (пустые `created` или `field_name`, запись без `id`), агент отбрасывает с записью в лог — иначе один кривой элемент давал бы 422 на пачку в каждом прогоне и отметка не сдвинулась бы никогда. 9. **Агент v13: id поля.** `field_id` — `fieldId` элемента журнала, если Jira его отдаёт; иначе для `fieldtype = custom` агент ищет id по имени поля в `GET /rest/api/2/field` (один вызов на `refresh`, только если такой элемент встретился); для `fieldtype = jira` — `field` как есть. Не нашлось — `null`. 10. **Приём журнала — `POST /agent/history`.** Тело `{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from, from_string, to, to_string}`. Агент шлёт пачками по 500 элементов; хаб проверяет форму всей пачки до записи (`history_id`, `key`, `created`, `field_name` — непустые строки, `seq` — целое ≥ 0, остальные — строка или `null`; нарушение — 422 `bad_history` с номером элемента и полем, пачка не применяется) и пишет `INSERT OR IGNORE`. Ответ `{accepted, duplicates}`. Отдельный маршрут, а не поле в `/agent/state`: журнал тянется после состояния, у него свои пачки, контракт состояния не меняется. 11. **Итог журнала — в результате `refresh`.** `result.history = {issues: <задач с журналом>, changes: <элементов отправлено>, incomplete: [ключи с неполным журналом]}` плюс, при ошибке, `error` (регулярная часть, `since`) или `backfill_error` (стартовая загрузка, `jql`). Ответ не 2xx от Jira или хаба на шаге журнала (`HttpError`, у хаба — с префиксом «хаб: ») не роняет `refresh`: агент сразу, без повтора команды, возвращает `done` с текстом ошибки — зеркало уже обновлено, ломать и перекачивать его ради журнала нельзя (анти-цель); повторит следующий прогон, отметка ведь не сдвинулась. Обрыв связи с Jira или хабом (`ConnectionError`) на шаге журнала — как в остальном `refresh` (повтор, затем `failed`). Без `history` в параметрах агент журнал не читает и ключа `history` в результате нет. Хаб ставит `sync_run.history_at` при `done` полного прогона и `history` без `error`; `backfill_error` и `incomplete` отметку не держат (стартовая загрузка разовая — решение 6; неполнота — решение 8). 11a. **Журнал в `sync_log`.** `GET /sync-runs` отдаёт `history_at`; MCP `sync_log` выводит у прогона «журнал: принят <время>» или «журнал: нет». Текст ошибки журнала — в результате команды (`batch_status`). 12. **Лента — `GET /changes`.** Параметры: `since`, `until` (ISO-8601 со смещением или `Z`; без `until` — сейчас; без `since` — `until − 24 ч`), `project` (только свои задачи проекта; соседи — нет: в ленте проекта чужие задачи — шум), `key` (одна задача, без ограничения проектом), `kinds` (через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию все, кроме `other`), `limit` (по умолчанию 500, до 2000). Ответ `{since, until, changes: [...], truncated}`, элемент — `{at, key, summary, status, kind, field, author, from, to, from_value, to_value}` (`from`/`to` — `from_string`/`to_string`, а если они `null` — `from_value`/`to_value`; `from_value`/`to_value` — сырые значения журнала: у дат Jira строка журнала может быть локализованной, а значение — ISO, им пользуется MCP; у комментария `from = null`, `to` — текст, `field = "comment"`, `*_value = null`); порядок — `at` по убыванию, затем `key`, `history_id`, `seq`. Неизвестный проект — 404, неверное время или вид — 422 `bad_request` с именем параметра. 13. **История задачи — в `GET /issues/{key}`.** Поле `changes` — все элементы журнала задачи (все виды, включая `other`), в формате элемента ленты без `summary`/`status`, новые сверху. Комментарии там уже есть отдельно. 14. **MCP `changes`.** Параметры `hours` (число), `since`, `until`, `kinds`, `key`. Без `hours` и `since` — с начала сегодняшнего дня по местному времени машины MCP. Привязка: в сессии проекта P — `project = P`; с `key` — без проекта; без привязки и без `key` — ответ «сессия не привязана к проекту…» (как `issues`). Вид ответа — по задачам (задачи — по времени последнего события, новые сверху): строка «КЛЮЧ тема — статус», под ней события по времени («ЧЧ:ММ автор: статус «В работе» → «Готово»»; дата перед временем, если событие не сегодня; описание — «описание изменено» без текста; срок — «срок 25.10 → 30.10» (дата — из начала `*_value` вида `ГГГГ-ММ-ДД`, иначе строка как есть); комментарий — первые 200 символов с «…»). Пусто — «за период изменений нет». `truncated` — строка «показаны последние N событий, сузьте период». 15. **MCP `issue`.** После комментариев — «изменения (N):» и 5 последних элементов `changes` в том же виде, «ещё M», если их больше; без журнала — строки нет (у старых задач журнала может не быть). 16. **Миграция v4 → v5.** В той же транзакции с копией базы, что и прежние: `issue_change` и индексы создаёт `SCHEMA`; шаг — `ALTER TABLE issue_comment ADD COLUMN created_at TEXT`, `ALTER TABLE sync_run ADD COLUMN history_at TEXT`, заполнение `issue_comment.created_at` разбором `created` (Python-шаг, как `migrate_v3`). У имеющихся `sync_run` `history_at = NULL` → первый `refresh` после выкатки хаба — стартовая загрузка. 17. **Порядок выкатки.** Хаб можно выкатить раньше агента: агент v12 игнорирует `history`, результат без `history` — отметка не сдвигается, `history_at` пуст, каждый полный `refresh` выдаётся со стартовой JQL, но агент её не исполняет. После публикации v13 первый полный прогон делает стартовую загрузку. Агент v13 со старым хабом (без `history` в параметрах) журнал не читает. ## Risks / Trade-offs - **Объём стартовой загрузки.** Журнал задач открытого спринта целиком; у задач с длинной историей описания — большие тексты (`from_string`/`to_string` описаний), идут через общую папку. Пачки по 500 элементов; если пачка окажется слишком тяжёлой для реле — уменьшить в агенте. - **Расхождение часов > 15 минут** между Mac mini и сервером Jira даёт пропуски. Цена — потеря событий без сигнала; принимаем, часы синхронизируются по NTP. - **Удалённый комментарий** исчезает из ленты задним числом (лента читает `issue_comment`). - **Журнал соседей.** Соседи, уже известные хабу, полным прогоном не перечитываются (поведение `refresh` v12), поэтому их изменения попадают в журнал только при точечном обновлении. Принимаем: лента проекта показывает свои задачи. ## Migration Plan 1. PR хаба: схема v5, приём журнала, `history` в выдаче `refresh`, `GET /changes`, `changes` в `GET /issues/{key}`, MCP. После merge — `git pull`, перезапуск хаба владельцем, `/mcp` → reconnect. 2. PR агента: v13. После merge — `jira-hub agent publish ../jira-agent/src/agent.py`; `agent_status` показывает v13; `min_agent_version` не поднимать (хаб работает и со старым агентом). 3. Живая проверка: полный `refresh` (выдан с `history.jql`) → в `result.history` элементы, в ленте события задач спринта; следующий полный `refresh` (выдан с `history.since`) → лишних вызовов Jira нет при отсутствии изменений; правка задачи в Jira → событие в `changes` с автором и временем; лог агента — нет строк о неполном журнале (иначе — решение 8 под пересмотр). Откат: агент — публикация v12 невозможна (только вверх), выкатывается v14 без журнала; хаб — предыдущий коммит, таблица и колонки v5 остаются и не мешают (старый код их не читает), `schema_version = 5` старый хаб не понизит. ## Open Questions Нет. ## Decisions made without discussion 1. 2026-10-22 — **строка журнала — одно поле одной записи Jira (`issue_change`), PK `(history_id, seq)`.** Обоснование: фильтр ленты по виду поля; идемпотентность по id Jira. Цена ошибки: если `seq` у одной записи Jira окажется неустойчивым между запросами, возможны дубли полей одной записи — видно в ленте, лечится сменой ключа на `(history_id, field_name, to_value)`. 2. 2026-10-22 — **сохраняются все поля журнала, лента по умолчанию показывает четыре вида.** Обоснование: данные приходят бесплатно, веб и история задачи покажут больше. Цена ошибки: рост базы — текстами описаний. 3. 2026-10-22 — **отметка «с какого момента» — из `sync_run.history_at` любого полного прогона с принятым журналом (без сравнения `query`), сдвигается только им; запас 15 минут; после нового проекта — `since` и `jql` вместе; неполные `refresh` без отметки журнала не получают.** Обоснование: анти-цель «молча теряет» (находки рецензента: новый проект не должен обнулять отметку прежних; хвост пакета не должен тянуть стартовую загрузку). Цена ошибки: при расхождении часов > 15 минут — пропуски; агенту два режима в одном `refresh`. 4. 2026-10-22 — **стартовая загрузка — JQL `sprint in openSprints() AND statusCategory != Done`, журнал целиком, разово.** Обоснование: формулировка владельца; активный спринт в зеркале не отличим от будущего; `closed_statuses` со статусом, которого нет в Jira, дал бы 400 навсегда. Цена ошибки: статус с категорией не Done, который владелец считает закрытым, попадёт в стартовую загрузку (лишний журнал); канбан-проекты без стартовой истории; упавшая стартовая загрузка не повторяется (видно в `backfill_error`). 5. 2026-10-22 — **ошибка Jira или хаба (не 2xx) при чтении журнала не роняет `refresh` и не повторяет команду** (`done` с `history.error`, отметка не сдвигается). Обоснование: зеркало важнее ленты; повтор команды перекачал бы состояние ради журнала; следующий прогон дочитает. Цена ошибки: при разовом 5xx лента отстаёт на один интервал автообновления. 6. 2026-10-22 — **логическое имя поля вычисляет хаб, агент шлёт `field_id` и `field_name`.** Обоснование: соответствие id полей — в настройках хаба. Цена ошибки: если Jira не отдаёт `fieldId` и агент не нашёл id по имени — плановые даты попадут в `other`; видно на живой проверке. 7. 2026-10-22 — **комментарии в ленте — из `issue_comment` по новой колонке `created_at` (UTC), а не событиями журнала.** Обоснование: комментариев нет в журнале Jira; они уже приходят с автором и временем. Цена ошибки: удалённый в Jira комментарий пропадает из ленты. 8. 2026-10-22 — **лента проекта — только свои задачи, без соседей.** Обоснование: соседи — задачи других меток, в ленте проекта это шум. Цена ошибки: изменение блокирующей задачи другого проекта не видно в ленте (видно через `key`). 9. 2026-10-22 — **MCP `changes` по умолчанию — с начала сегодняшнего дня по местному времени.** Обоснование: «что закрылось сегодня» — первый сценарий владельца. Цена ошибки: лишний параметр в вопросе «за 2 часа» (`hours: 2`). 10. 2026-10-22 — **история задачи — полем `changes` в `GET /issues/{key}`, а не отдельным маршрутом.** Обоснование: так уже отдаются комментарии; карточка и MCP `issue` получают всё одним запросом. Цена ошибки: тяжелее ответ карточки у задач с длинной историей. 11. 2026-10-22 — **событие «задача создана» не входит в ленту.** Обоснование: владелец его не называл, в журнале Jira его нет. Цена ошибки: новые задачи дня не видны в ленте; добавляется по `issue.created` без агента. 12. 2026-10-22 — **журнал приёмом `POST /agent/history` пачками по 500 элементов; ответ `{accepted, duplicates}`.** Обоснование: не менять контракт `/agent/state`; размер пачки — по опыту реле с состоянием. Цена ошибки: пачки описаний тяжелее пачек состояния — при сбоях реле уменьшить. 13. 2026-10-22 — **неполный журнал задачи не держит отметку; ключи — в `result.history.incomplete` и в логе.** Обоснование: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда. Цена ошибки: потерянные старые записи у задач с очень длинной историей; видно в результате команды и на живой проверке. 14. 2026-10-22 — **агент отбрасывает элементы, которые хаб отверг бы** (пустые `created`, `field_name`, нет `id`), с записью в лог. Обоснование: один кривой элемент иначе давал бы 422 на пачку в каждом прогоне. Цена ошибки: такой элемент не попадёт в ленту. 15. 2026-10-22 — **элемент ленты несёт и строки, и сырые значения (`from_value`/`to_value`); MCP показывает даты по значению.** Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее. 16. 2026-10-22 — **`sync_log` показывает, принят ли журнал прогона (`history_at`).** Обоснование: дизайн обещал видимость ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP. 17. 2026-10-22 — **R-1: шаги миграции v4 → v5 добавляют колонку, только если её нет (`_add_column`).** Обоснование: базы v1/v2 получают `issue_comment` из `SCHEMA` уже с `created_at`, голый `ALTER` падал бы «duplicate column». Цена ошибки: нет — для базы v4 поведение то же. 18. 2026-10-22 — **R-2: прежний тест формата `sync_log` переписан на строку с «, журнал: нет».** Обоснование: спека `mcp` добавляет журнал к каждой строке. Цена ошибки: нет. 19. 2026-10-22 — **R-7 (по I-1 финального ревью): агент выполняет стартовую часть журнала первой, регулярная не запрашивает уже прочитанные задачи; задача помечается прочитанной только после разбора её журнала.** Обоснование: при `history = {since, jql}` задача из обеих выборок теряла записи старше `since` (их отсекала регулярная часть, а стартовая её пропускала), хотя полный журнал уже был получен; спека `agent` уточнена. Цена ошибки: нет лишних вызовов Jira; если стартовая часть упала на задаче, её добирает регулярная (только записи после `since`). 20. 2026-10-22 — **R-8 (по M-1/M-2 финального ревью, подняты до Important по эффекту): время за краем диапазона `datetime` — «не разобралось» (`None`), а не исключение; `GET /changes` отвечает 422 на время без смещения, MCP `changes` считает такое время местным.** Обоснование: исключение роняло миграцию (хаб не стартует) или навсегда стопорило отметку журнала; время без смещения из чата молча сдвигало окно ленты на 3 часа. Цена ошибки: запрос API без смещения теперь 422 — интерфейсов, которые так шлют, нет.