28 KiB
data-model Specification
Purpose
Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы.
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 = <M> или … labels in (<M1>, …) и завести проекты с этими метками (название проекта
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 стоит
queuedrefresh {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
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:<n>),
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-1640work_kind = "Аналитика"иowner_note = "спросить ИБ"; строкиnew:5нет
Requirement: Пакеты и команды
Таблица batch SHALL хранить id, title, author, created_at, cancelled_at, estimate_seconds, project (метка
проекта, если пакет поставлен в разрезе проекта, иначе NULL).
Таблица 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, команда 2claimed, команда 3queued; пакет отменён, затем агент отчитался по 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_runissues_count = 150
Scenario: refresh сдался по пределу выдач
- WHEN
refreshпереведён вfailed«агент не отчитался после 3 выдач» - THEN у его
sync_runпроставленfinished_at
Requirement: Настройки
Таблица setting SHALL хранить парами ключ–JSON: project_key, field_ids, issue_types, priority_map, link_types,
transitions_map, pacing, tokens (агентский и клиентский), settings_version (увеличивается при любой
правке), backup_keep, auto_refresh_minutes, relay_dir (абсолютный путь папки реле или null, по умолчанию null;
относительный путь и не-строка при записи — 422; агенту не передаётся), closed_statuses (список имён статусов Jira, считающихся закрытыми, по умолчанию
["Готово", "Снято"]; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением
по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование
«Проекты»); на новой базе ключей base_jql и board_id нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме
передачи board_id агенту как запасной доски для команд без доски в параметрах (поставленных до миграции);
GET /settings SHALL отдавать вычисленную base_jql только для чтения, а запись base_jql или board_id через
PUT /settings — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения 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не изменился
Scenario: Закрытые статусы по умолчанию
- WHEN хаб открыл базу, в которой нет ключа
closed_statuses - THEN
GET /settingsотдаётclosed_statuses = ["Готово", "Снято"];claimагенту этого ключа не передаёт
Scenario: Запись базовой JQL
- WHEN
PUT /settings {base_jql: "project = AURORA"} - THEN 422 «базовая JQL собирается из проектов»;
settings_versionне изменился
Scenario: Создание проекта не трогает версию настроек
- WHEN создан проект
STOCKS_DRRK - THEN
settings_versionпрежний;GET /settingsотдаётbase_jqlс обеими метками
Requirement: Проекты
Таблица project SHALL хранить проекты моста — подмножества задач Jira-проекта project_key, выделенные меткой:
label (PK, метка Jira), name, board_id (номер доски Jira или NULL), board_type (scrum | kanban | NULL —
тип ещё не известен), created_at. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория
Unicode Cc), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть
непустым после обрезки пробелов, до 100 символов. board_id — целое больше нуля или NULL. board_type SHALL
записываться только по результату refresh от агента (см. agent). Метку, название и доску проекта после создания
хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов:
project = <project_key> AND labels in ("<метка>", …) — каждая метка в двойных кавычках, метки по created_at, затем
label, через , ; без проектов базовой JQL
нет (null).
Для проекта P задачи зеркала делятся на свои — с меткой P в labels (точное совпадение) — и соседей — задачи без
метки P, связанные со своей связью issue_link в любую сторону, и эпики своих (epic_key своей задачи), если они есть в
зеркале. Признак neighbor в выдаче в разрезе проекта SHALL считаться относительно P: false у своих, true у соседей.
Scenario: Базовая JQL из двух проектов
- WHEN заведены проекты
AWPCA(раньше) иSTOCKS_DRRK,project_key = AURORA - THEN базовая JQL —
project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")
Scenario: Свои и соседи
- WHEN у AURORA-1 метка
STOCKS_DRRKи эпик AURORA-9 (меткаAWPCA), связь AURORA-1 → AURORA-2 (меткаAWPCA), у AURORA-3 меткаAWPCAбез связей с проектомSTOCKS_DRRK - THEN в разрезе
STOCKS_DRRKсвои — AURORA-1, соседи — AURORA-2 и AURORA-9; AURORA-3 в разрез не входит
Scenario: Метка с пробелом
- WHEN создаётся проект с меткой
STOCKS DRRK - THEN отказ с указанием поля
label, проекта нет
Scenario: Метка с обратной косой чертой или управляющим символом
- WHEN создаётся проект с меткой
A\BилиA<перевод строки>B - THEN отказ с указанием поля
label, проекта нет
Scenario: Метка — зарезервированное слово JQL
- WHEN заведён проект с меткой
EMPTY - THEN базовая JQL —
project = AURORA AND labels in ("EMPTY"), метка не расширяет выборку
Scenario: Та же метка другим регистром
- WHEN есть проект
AWPCA, создаётся проектawpca - THEN отказ «проект с такой меткой уже есть», проект один
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_runhistory_at = NULL
Scenario: Старый агент
- WHEN агент v12 завершил
refreshdoneбезresult.history - THEN у его
sync_runhistory_at = NULL