# data-model Specification ## Purpose Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы. ## Requirements ### Requirement: Схема базы хаба База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`, `command`, `sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии в `setting.schema_version` (текущая — 3). Миграция существующей базы SHALL выполняться в одной транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в `backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени комментариев (строки Jira как есть). #### 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 = 3` #### Scenario: Миграция базы v2 - **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки - **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 3` #### Scenario: Два соединения к базе v1 - **WHEN** к базе `schema_version = 1` одновременно открываются два соединения - **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` #### Scenario: Открытый каталог данных - **WHEN** хаб стартует, каталог данных существует с правами `0755` - **THEN** права каталога и `backups/` — `0700` ### Requirement: Зеркало задачи Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), `status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), `sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть: `key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Обновлять `issue`, `issue_link` и `issue_comment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии SHALL сохранять. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не обновлённые им, — только если прогон загрузил хотя бы одну задачу. #### Scenario: Загрузка состояния заменяет связи - **WHEN** агент прислал состояние задачи X с двумя связями - **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные со стороны X, не трогаются до загрузки их источника #### Scenario: Соседняя задача - **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL - **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` #### Scenario: Пустой полный прогон - **WHEN** полный `refresh` завершился `done`, загрузив 0 задач - **THEN** признак `neighbor` задач зеркала не меняется #### Scenario: Комментарий удалён в Jira - **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]` - **THEN** у X в `issue_comment` только 102 #### Scenario: Состояние от агента до v10 - **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments` - **THEN** поля X обновлены, три комментария на месте ### Requirement: Служебные заметки Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), `fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL перенести заметку на настоящий ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой или переведённой в `failed` до отчёта. Если под настоящим ключом заметка уже есть, хаб SHALL слить их: каждое поле берётся из временной заметки, если там оно непустое, иначе остаётся под настоящим ключом; `updated_at`/`updated_by` — от более поздней; временная строка удаляется. #### Scenario: Заметка переживает refresh - **WHEN** у задачи есть `issue_note.fact_status = "В работе"` и приходит полное обновление зеркала - **THEN** `fact_status` не изменился #### Scenario: Слияние с заметкой под ключом - **WHEN** у `new:5` заметка `work_kind = "Аналитика"`, `owner_note` пусто; у `AURORA-1640` заметка `owner_note = "спросить ИБ"`; `ensure_issue` для `new:5` завершилась с `AURORA-1640` - **THEN** у `AURORA-1640` `work_kind = "Аналитика"` и `owner_note = "спросить ИБ"`; строки `new:5` нет ### Requirement: Пакеты и команды Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`. Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` (`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи), `deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`, `rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе — «выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»; все терминальные без отмены — «завершён». #### Scenario: Статус пакета - **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` - **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` #### Scenario: Отменённый пакет с выполненной командой - **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2 - **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled` ### Requirement: Журнал синхронизаций и статус агента Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, `issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed` по пределу выдач — проставить `finished_at`. Таблица `agent_status` (одна строка) SHALL хранить `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP `agent_status` SHALL отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в `claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). #### Scenario: Агент давно не выходил на связь - **WHEN** `last_seen_at` старше 5 минут - **THEN** `agent_status` через API и MCP отдаётся с `online = false` #### Scenario: Довыдача refresh не удваивает счётчики - **WHEN** `refresh` загрузил 150 задач, отчёт потерян, команда выдана снова и загрузила те же 150 - **THEN** у её `sync_run` `issues_count = 150` #### Scenario: refresh сдался по пределу выдач - **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач» - **THEN** у его `sync_run` проставлен `finished_at` ### Requirement: Настройки Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, `transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; относительный путь и не-строка при записи — 422; агенту не передаётся). Спринты агент читает с доски `board_id`, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект `{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига `issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). #### Scenario: Правка настроек поднимает версию - **WHEN** через API изменён `pacing` - **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию #### Scenario: Конфиг без ключей ошибки - **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` - **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 #### Scenario: Неверный relay_dir - **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` - **THEN** 422, значение не записано, `settings_version` не изменился