## MODIFIED Requirements ### Requirement: Схема базы хаба База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_note`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии в `setting.schema_version` (текущая — 5). Миграция существующей базы SHALL выполняться в одной транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в `backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) и значений полей журнала. Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта `AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в `queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи `base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с прежним значением. Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и `sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. #### Scenario: Первый запуск - **WHEN** хаб стартует с несуществующим файлом базы - **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` #### Scenario: Миграция базы v1 - **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` - **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая `issue_comment`; данные на месте; `schema_version = 5` #### Scenario: Миграция базы v2 - **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки - **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 5` #### Scenario: Миграция базы v3 - **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, в `sync_run` полный прогон с этой `query` - **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, пакеты на месте; `schema_version = 5` #### Scenario: Миграция базы v4 - **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`, в `sync_run` полный прогон - **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`, `created` прежний; у прогона `history_at = NULL`; `schema_version = 5` #### Scenario: refresh в очереди при миграции - **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` - **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается полным прогоном #### Scenario: Два соединения к базе v1 - **WHEN** к базе `schema_version = 1` одновременно открываются два соединения - **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` #### Scenario: Открытый каталог данных - **WHEN** хаб стартует, каталог данных существует с правами `0755` - **THEN** права каталога и `backups/` — `0700` ## ADDED Requirements ### Requirement: Журнал изменений задач Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом), `created` (время Jira как есть), `author`, `field` (логическое имя), `field_id`, `field_name`, `from_value`, `from_string`, `to_value`, `to_string`, `fetched_at`; PK `(history_id, seq)`, индексы по `at` и по `(key, at)`. Записывать в `issue_change` SHALL только приём журнала от агента (`POST /agent/history`); повторно присланная строка с тем же `(history_id, seq)` MUST NOT менять сохранённую. HTTP API интерфейсов и MCP MUST NOT писать в `issue_change`. Логическое имя `field` SHALL вычисляться хабом при приёме: `status`, `description`, `summary`, `assignee`, `priority`, `duedate`, `labels`, `resolution` — если так называется `field_id`, а при его отсутствии — `field_name`; `planned_start`, `planned_end` — если `field_id` равен id этого поля в настройке `field_ids`; `epic_link` — если `field_id` равен `epic_link.field_id`; иначе — `field_name` как есть. Время Jira (`created` журнала, `created` комментария) SHALL приводиться к UTC разбором ISO-8601 со смещением вида `+0300`, `+03:00` или `Z`, с дробной частью секунд или без; время без смещения считается UTC; неразбираемое — `NULL` в приведённой колонке (строка журнала с `at = NULL` в ленту не попадает). Таблица `issue_comment` SHALL хранить также `created_at` — `created`, приведённое к UTC, заполняемое при каждой записи комментариев. #### Scenario: Повтор записи журнала - **WHEN** агент дважды прислал элемент `{history_id: "5001", seq: 0, key: X, field_name: "status", …}` - **THEN** в `issue_change` одна строка `(5001, 0)`; второй приём ничего не изменил #### Scenario: Плановая дата по id поля - **WHEN** в настройках `field_ids.planned_end = "customfield_10201"`, агент прислал элемент с `field_id = "customfield_10201"`, `field_name = "Плановое окончание"` - **THEN** у строки `field = "planned_end"` #### Scenario: Неизвестное поле - **WHEN** агент прислал элемент с `field_id = null`, `field_name = "Fix Version"` - **THEN** у строки `field = "Fix Version"` #### Scenario: Время со смещением - **WHEN** элемент журнала с `created = "2026-10-22T09:00:00.000+0300"` - **THEN** у строки `at = "2026-10-22T06:00:00+00:00"`, `created` — как прислано ### Requirement: Отметка журнала в журнале синхронизаций Таблица `sync_run` SHALL хранить `history_at` — время, когда хаб принял итог журнала по этому прогону (`NULL` — журнал по прогону не принят). Хаб SHALL проставлять `history_at` при отчёте полного прогона (`refresh` по базовой JQL) со статусом `done` и `result.history` без `error`; у прочих `refresh`, при `failed`, `cancelled`, без `result.history` или с `history.error` — оставлять `NULL`. `history.backfill_error` и непустой `history.incomplete` отметку не держат. #### Scenario: Журнал принят - **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 3, changes: 7, incomplete: []}` - **THEN** у его `sync_run` проставлен `history_at` #### Scenario: Ошибка стартовой загрузки не держит отметку - **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 1, changes: 2, incomplete: [], backfill_error: "HTTP 400 …"}` - **THEN** у его `sync_run` проставлен `history_at` #### Scenario: Ошибка журнала - **WHEN** `refresh` завершился `done` с `result.history = {error: "HTTP 500 …"}` - **THEN** у его `sync_run` `history_at = NULL` #### Scenario: Старый агент - **WHEN** агент v12 завершил `refresh` `done` без `result.history` - **THEN** у его `sync_run` `history_at = NULL`