Andrey Epifancev 534e43865b Архив OpenSpec: change-history (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:06:16 +04:00

33 KiB
Raw Blame History

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 = (<base_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 — интерфейсов, которые так шлют, нет.