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

295 lines
30 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.

# commands Specification
## Purpose
Контракт команд моста: каждая команда — желаемое состояние одной задачи Jira (`ensure_*`) или чтение состояния (`refresh`). Здесь типы команд и их параметры, временные ключи внутри пакета, порядок выполнения, политика темпа, обработка ошибок, повторная выдача и подтверждение по состоянию зеркала.
## Requirements
### Requirement: Команда — желаемый результат на одной задаче
Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить →
записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done`
без записи. Типы команд и их контракт:
| Тип | Параметры | Что читает | Что пишет |
|---|---|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = <proj> AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.<issue_type>` (для эпика — с `epic_name_field_id`); результат `key` |
| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` |
| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет |
| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных |
| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием |
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` |
| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` |
| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"<id>": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал |
Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в
зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` —
доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта
нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P
ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе
не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и
`kanban` в проекты с этой доской; `null` тип не меняет.
Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в
`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026).
Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira
без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный
в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026).
#### Scenario: Повтор уже выполненной команды
- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10
- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1`
#### Scenario: Описание отличается только переводами строк
- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"`
- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []`
#### Scenario: Замена связи
- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между
ними «блокирует начало» 1537→1538
- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями
#### Scenario: Связь-конфликт без replace
- **WHEN** та же команда с `replace: false`
- **THEN** `failed`, в `error` найденная связь, Jira не изменена
#### Scenario: Переход по пути
- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки»
- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи
#### Scenario: Создание ошибки
- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках
`issue_types.bug = {name: "Ошибка"}` без `id`
- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом
#### Scenario: Вложение уже есть
- **WHEN** `ensure_attachment {key: X, file: <sha>, filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того
же размера
- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1`
#### Scenario: Вложение с тем же именем, другого размера
- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера
- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id`
#### Scenario: Создание с проверкой дубля
- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад
- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной
#### Scenario: Спринт задачи проекта
- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}`
- **THEN** пакет создан, в параметрах команды `board_id = 4216`
#### Scenario: Спринт на канбане
- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}`
- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет»
#### Scenario: Тип доски определён
- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}`
- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban`
### Requirement: Временные ключи и ссылки внутри пакета
Параметр-ключ любой команды MAY быть временным `new:<n>`. Хаб SHALL выдавать агенту такую команду только после `done`
команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась
`failed` или `cancelled`, зависимые команды SHALL получить `skipped` с ошибкой «создание задачи new:<n> отменено», если
`ensure_issue` отменена, иначе «задача new:<n> не создана». Временный ключ вне пакета с его `ensure_issue` SHALL
отвергаться при создании пакета (422).
#### Scenario: Создать и привязать
- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}`
- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой
#### Scenario: Создание отменено
- **WHEN** пакет: `ensure_issue {temp_key: new:901, …}` (отменена с `force: true`), `ensure_comment {key: new:901, …}`
- **THEN** `ensure_comment` — `skipped` с ошибкой «создание задачи new:901 отменено»
#### Scenario: Создание упало
- **WHEN** `ensure_issue {temp_key: new:902, …}` завершилась `failed`, зависимая `ensure_link {source: new:902, …}`
- **THEN** `ensure_link` — `skipped` с ошибкой «задача new:902 не создана»
### Requirement: Пакет и порядок выполнения
Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка
и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Хаб SHALL проверять
типы параметров: текстовые параметры и ключи — строки (`null` у необязательного параметра — «не задан»; агент
трактует его по контракту типа, для `ensure_fields` — очистка поля), `target` команды
`ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` —
логическое значение; нарушение — 422 с именем параметра. Пакет с двумя и более `ensure_issue` с одинаковой `summary`
(после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать команды в порядке
`(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed` команда MUST NOT
отменяться автоматически.
#### Scenario: Оценка длительности
- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment`
- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh`
#### Scenario: Две задачи с одной темой
- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}`
- **THEN** 422 с темой «Отчёт», пакет не создан
#### Scenario: Параметр не того типа
- **WHEN** пакет содержит `ensure_fields {key: X, labels_add: "AWPCA"}`
- **THEN** 422 с указанием `labels_add`, пакет не создан
### Requirement: Политика темпа и пауза на команде
Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и
`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30, `refresh` 2). При выдаче команды
хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего
последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент
SHALL выдерживать зазор типа команды между записями.
#### Scenario: Нет следующей команды — нет паузы
- **WHEN** очередь пуста
- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса
### Requirement: Ошибки и подтверждение по состоянию
Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`,
обрезая присланное. Пакет после ошибки продолжается.
**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому
команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL
сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры,
`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL
перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed`
или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать
сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался
`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести
заметку, не меняя статус (как для отменённой, см. `hub-api`).
**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб
SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия
`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь
есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`, `ensure_attachment` и `refresh` по
состоянию не подтверждаются (вложений в зеркале нет): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля)
или отменой человеком.
#### Scenario: Отчёт потерян, факт на месте
- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2»
- **THEN** команда `confirmed_by_state`
#### Scenario: Описание с CRLF в зеркале
- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"`
- **THEN** команда `confirmed_by_state`
#### Scenario: Ответ на claim потерян
- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim`
- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40
#### Scenario: Предел выдач
- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42
- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42
#### Scenario: Потерянный отчёт о создании задачи
- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim`
- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй
задачи нет, заметка `new:5` перенесена на ключ
### Requirement: Описание параметров и ошибки валидации
Описание параметров каждого типа команды (обязательные, необязательные, формат значения, условие на набор) SHALL
храниться в коде хаба в одном месте с правилами валидации; описание инструмента MCP `batch_create` SHALL собираться из
него (см. `mcp`). Ошибка валидации «нет <параметры>» и «лишние параметры <параметры>» SHALL заканчиваться ожидаемым
набором параметров типа: «ожидаются <обязательные>» и, если у типа есть необязательные, «необязательные <список>».
#### Scenario: Неверное имя параметра
- **WHEN** `POST /batches` с `ensure_status {key: X, status: "Готово"}`
- **THEN** 422 «команда 1 (ensure_status): нет target; ожидаются key, target»
#### Scenario: Лишний параметр у типа с необязательными
- **WHEN** `POST /batches` с `ensure_link {source: X, target: Y, link_type: "связанные", force: true}`
- **THEN** 422 «команда 1 (ensure_link): лишние параметры force; ожидаются source, target, link_type; необязательные replace»
### Requirement: Проверки ensure_issue с типом bug и ensure_attachment при постановке
При постановке пакета хаб SHALL отвергать 422 без создания пакета: `ensure_issue` с `issue_type = bug`, если в настройках
нет `issue_types.bug` с непустым `name` или `id` («нет настройки issue_types.bug»); `ensure_attachment`, если `file` — не
64 hex-символа в нижнем регистре, файла с таким sha256 нет в хранилище хаба («файл <sha> не загружен: POST /files»),
`filename` пустой (или из одних пробелов) или содержит `/`, `\`, `"` или управляющие символы (код < 32 и 127,
в том числе CR и LF): такие имена ломают `multipart/form-data`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту
(`claim`, в том числе повторной) хаб SHALL добавить к параметрам `ensure_attachment` `size` — текущий размер файла в
хранилище, или `size = null`, если файла уже нет; в базе параметры команды остаются без `size`, поэтому повтор команды
(`POST /commands/{id}/retry`) проходит проверку как новая.
#### Scenario: Ошибка без настройки типа
- **WHEN** `POST /batches` с `ensure_issue {issue_type: bug, …}`, в `issue_types` только `task` и `epic`
- **THEN** 422 «команда 1: нет настройки issue_types.bug», пакет не создан
#### Scenario: Вложение без загруженного файла
- **WHEN** `POST /batches` с `ensure_attachment {key: X, file: <64 hex>, filename: "a.xlsx"}`, файла нет в хранилище
- **THEN** 422 «команда 1: файл <sha> не загружен: POST /files», пакет не создан
#### Scenario: Ошибка с вложением одним пакетом
- **WHEN** пакет `[ensure_issue {temp_key: new:1, issue_type: bug, …}, ensure_sprint {key: new:1, sprint: "Спринт 2"},
ensure_attachment {key: new:1, file: <sha>, filename: "ОСВ 60.xlsx"}]`, файл загружен
- **THEN** пакет создан; `ensure_attachment` выдаётся после завершения `ensure_issue` с подставленным ключом и `size` файла
### Requirement: Пакет проекта
Пакет, поставленный с `project = P` (см. `hub-api`), SHALL проходить при постановке, до записи, проверки проекта;
любая неудача — 422 с ключом задачи и причиной, пакет не создаётся:
- `P` нет среди проектов — 422;
- `ensure_issue`: если в `labels` нет P, хаб SHALL дописать P в конец `labels` (в базе параметры хранятся уже с ней;
без `labels` — `labels = [P]`); метка, совпадающая с P без учёта регистра, но не точно, SHALL заменяться на P; метка
другого проекта в `labels` — 422;
- `ensure_fields`: P в `labels_remove` или метка другого проекта в `labels_add` — 422;
- `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным
ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 «<key> не в проекте P»;
- `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P;
- `refresh` — без проверок проекта.
«Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту
(метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит,
метки хаб в нём не дописывает.
#### Scenario: Метка проекта дописана
- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {temp_key: new:1, summary: "Сверка остатков", labels: ["kind-dev"]}`
- **THEN** пакет создан; в параметрах команды `labels = ["kind-dev", "STOCKS_DRRK"]`
#### Scenario: Чужая метка проекта
- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {…, labels: ["awpca"]}`, есть проект `AWPCA`
- **THEN** 422 «метка другого проекта: awpca», пакет не создан
#### Scenario: Задача другого проекта
- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_fields {key: AURORA-1538, duedate: …}`, у AURORA-1538 метка только `AWPCA`
- **THEN** 422 «AURORA-1538 не в проекте STOCKS_DRRK»
#### Scenario: Связь с соседом
- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_link {source: AURORA-1 (метка STOCKS_DRRK), target: AURORA-1538 (метка AWPCA), …}`
- **THEN** пакет создан
### Requirement: Журнал изменений в refresh
Отметка журнала — `started_at` последнего `sync_run` с `history_at IS NOT NULL` (любой `query`). JQL стартовой загрузки
— `(<базовая JQL>) AND sprint in openSprints() AND statusCategory != Done`. Хаб SHALL при каждой выдаче `refresh`
(первой и повторной) дописывать в параметры `history` (в базе `params` без него, как `boards`):
- полный прогон (`refresh` по базовой JQL), отметка есть — `{since: <отметка − 15 минут, ISO-8601 UTC с Z>}`; если
`query` прогона-отметки не равна текущей базовой JQL — также `jql: <JQL стартовой загрузки>`;
- полный прогон, отметки нет — `{jql: <JQL стартовой загрузки>}`;
- `refresh {keys}` и `refresh` по небазовой JQL — `{since: …}`, если отметка есть; иначе `history` не дописывается;
- без базовой JQL (нет проектов) — `history` не дописывается.
Результат `refresh` MAY содержать `history` (см. `agent`, «Журнал изменений»); хаб SHALL принимать результат и без
него. Отметку (`sync_run.history_at`) хаб SHALL проставлять по правилам `data-model`, «Отметка журнала в журнале
синхронизаций».
#### Scenario: Первый refresh после выкатки
- **WHEN** проект `AWPCA`, прогонов с `history_at` нет; выдаётся полный `refresh`
- **THEN** в выданных параметрах `history = {jql: '(project = AURORA AND labels in ("AWPCA")) AND sprint in
openSprints() AND statusCategory != Done'}`; в базе `params` без `history`
#### Scenario: Регулярный refresh
- **WHEN** последний полный прогон с `history_at` начат в `2026-10-22T09:00:00+00:00` по текущей базовой JQL; выдаётся
автообновление
- **THEN** `history = {since: "2026-10-22T08:45:00Z"}`
#### Scenario: Журнал не дочитан
- **WHEN** прогон в 10:00 завершился `done` с `history.error`, прогон в 09:00 — с принятым журналом; выдаётся следующий
полный `refresh`
- **THEN** `history.since` — от прогона 09:00
#### Scenario: Новый проект
- **WHEN** прогон с `history_at` в 09:00 по JQL только с `AWPCA`; затем создан проект `STOCKS_DRRK`; выдаётся полный
`refresh`
- **THEN** `history = {since: "…T08:45:00Z", jql: <стартовая по новой базовой JQL>}`
#### Scenario: Хвост пакета
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, отметка есть
- **THEN** `history = {since: …}`; по завершении `done` с `result.history` у его `sync_run` `history_at = NULL`
#### Scenario: Хвост пакета до первой отметки
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, прогонов с `history_at` нет
- **THEN** в выданных параметрах нет `history`