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

301 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 стоит `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`
### 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-1640` `work_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`, команда 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: `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_run` `history_at = NULL`
#### Scenario: Старый агент
- **WHEN** агент v12 завершил `refresh` `done` без `result.history`
- **THEN** у его `sync_run` `history_at = NULL`