Спека: пакет C бэкенда хаба hub-backend-fixes (#129)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:06:19 +04:00
parent 1c8d7f4f36
commit e786428a1d
6 changed files with 259 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-26

View File

@ -0,0 +1,73 @@
## Context
Пакет C сверки follow-ups 25.10.2026 (sdlc-hub#129). Все правки — в Python-коде хаба, без смены схемы базы и контракта
с агентами. Владелец пропустил вопросы brainstorming («не сталкивался и не хочу»): решения ниже приняты агентом и
продублированы в «Decisions made without discussion».
## Decisions
1. **Нормализация параметров — один шаг в `validate`.** После всех проверок `validate` кладёт в пакет параметры,
приведённые функцией `normalize(ctype, params) -> dict` (`modules/jira/commands.py`). Проверки на пустоту
(`.strip()`) остаются как есть. Обрезаются строковые параметры всех типов, кроме текстовых `summary`,
`description`, `body`, `goal`. Это ключи, `temp_key`, `sprint`, `rename_to`, `move_open_to`, `link_type`,
`assignee`, `priority`, даты, `marker`, `attachment_id`, `file`, `filename`, `issue_type`, `target` (строка или
элементы списка). Элементы списков `labels`, `labels_add`, `labels_remove`, `keys` тоже обрезаются. Числа,
логические значения и `null` не меняются. Нормализация возвращает новый словарь и не меняет вход.
2. **`filename` — NFC.** `normalize` приводит `filename` у `ensure_attachment` к `unicodedata.normalize("NFC", …)`.
MCP подставляет имя по умолчанию из `Path(path).name` и тоже приводит его к NFC; на macOS имена из пути приходят в
NFD. Поиск вложения по имени в MCP-инструменте `attachment` сравнивает имена в NFC с обеих сторон.
3. **Проверки `hooks.check_batch` идут по нормализованным параметрам.** `validate` вызывается раньше, поэтому
`filename` с пробелами по краям проверяется уже обрезанным.
4. **ASCII-цифры.** В `core/http.py` (рядом с `int_group`) появляется функция `is_digits(s) -> bool`: `s` — непустая строка только из
`0-9` (`s.isascii() and s.isdigit()`). Модуль jira использует её вместо `str.isdigit()` везде, где за проверкой
идёт `int()` или где строка — идентификатор: `hooks.py` (`attachment_id`), `mirror.py` (`check_attachments`),
`attachments.py` (сортировка), `fetch.py` (версия агента), `mcp.py` (ссылка `#<id>`). В разборе дат `history.py`
и `mcp.py` значение уже прошло форматную проверку, но для единообразия там тоже `is_digits`.
5. **Форма элементов состояния.** `mirror.check_issues(issues)` вызывается до `check_comments` и
`check_attachments`. Она требует: `issues` — список (иначе сейчас 500 на итерации), каждый элемент — объект с непустым строковым `key`. Иначе
`ValueError` с номером элемента. Обработчик `POST /agent/state` уже превращает `ValueError` в 422 `bad_state`,
как для комментариев.
6. **`POST /wiki/sync`.** Если `project` в теле задан и это не строка, ответ 422 `bad_request` «project — строка»
до обращения к проектам.
7. **Файлы хранилища.** `files.size` и `files.read` ловят `OSError`. `FileNotFoundError` по-прежнему тихо даёт `None`.
Остальные ошибки тоже дают `None`, но пишут в лог предупреждение с путём и ошибкой. Дальше всё идёт по
существующему пути «файла нет»: `size = null` при claim, 404 при скачивании.
8. **`SDLC_HUB_HOME=""`.** `paths.home()` берёт `os.environ.get("SDLC_HUB_HOME") or <по умолчанию>`. Пустая строка
равна незаданной переменной; `home_problem`/`home_warning` уже считают так.
9. **Восстановление прерванной публикации.** В начале `release.publish`, до проверки версии, вызывается
`_recover(rd)`. Если `agent.py.prev` есть, а `agent.py` нет или его sha256 не совпадает с манифестом, тогда как
sha256 `agent.py.prev` совпадает, `agent.py.prev` возвращается на место `agent.py` с предупреждением в лог. Если
`agent.py` совпадает с манифестом, а `.prev` лежит, `.prev` удаляется. Во всех остальных случаях ничего не
трогается, а в лог пишется предупреждение. Так закрываются T2.2 и M-6: после `_recover` в `agent.py` лежит
опубликованная версия, и `os.replace(agent, prev)` не затирает годный `.prev`.
10. **Устаревшие ключи настроек.** `base_jql` и `board_id` добавляются в `modules/jira/settings.OBSOLETE`.
`ensure_defaults` удаляет их при каждом открытии, не меняя `settings_version`, как `status_labels`. Запись через
`PUT /settings` по-прежнему даёт 422 с пояснением. Агенту `board_id` давно не передаётся: его нет в `AGENT_KEYS`.
## Risks / Trade-offs
- Нормализация меняет то, что раньше уходило агенту дословно. Пробелы по краям идентификатора в Jira не значимы,
а текстовые поля не трогаются.
- `_recover` срабатывает только при совпадении sha256 с манифестом, поэтому неизвестное содержимое не
перезаписывается.
- Удаление `board_id` из `setting` необратимо, но значение нигде не читается. Копия базы есть в `backups/`.
## Decisions made without discussion
- R-1 (proposal, 26.10.2026): O-3 (`check_confirmed` считает обратную связь симметричного типа конфликтом) не
входит в пакет и переносится в пакет B. Хаб не знает, какие типы симметричны: это знает агент по
`/issueLinkType`. Ручная настройка списка типов в хабе отвергнута, она дублировала бы Jira. Цена ошибки: при
потерянном отчёте команда `ensure_link` к «связанной» задаче, связанной в обратную сторону, подтверждается не по
состоянию, а довыдачей агенту, и агент отвечает existed. Лишний вызов Jira, данные не страдают.
- R-2 (design #1): обрезаются все строковые параметры, кроме `summary`, `description`, `body`, `goal`. Обоснование:
пробелы по краям идентификатора всегда ошибка ввода, а в тексте могут быть значимы. Цена ошибки: тема с пробелами
по краям уходит в Jira как есть, Jira обрезает её сама.
- R-3 (design #2): имя вложения приводится к NFC, а не к NFD. NFC — форма, в которой имена приходят из Jira и
набираются с клавиатуры. Цена ошибки: имя вложения в Jira будет в NFC, визуально то же самое.
- R-4 (design #7): ошибка доступа к файлу хранилища трактуется как «файла нет», а не как отдельная ошибка API.
Обоснование: путь «файла нет» уже понятен агенту и клиентам. Цена ошибки: причина видна только в логе хаба.
- R-5 (design #9): `_recover` чинит только состояния, однозначно определяемые по sha256 манифеста. Остальные
оставляет как есть с предупреждением в лог. Цена ошибки: в редком сочетании сбоев владелец публикует ещё раз.
- R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы.
Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова.
Цена ошибки: нет.

View File

@ -0,0 +1,45 @@
## Why
25.10.2026 сверка открытых follow-up issue с кодом подтвердила дефекты бэкенда хаба. Один из них уже приводит к
неверной команде агенту: ключ с пробелами из `ensure_rank` уходит в Jira как есть. Остальные проявляются при
нестандартном входе: на кривой запрос хаб отвечает 500 вместо понятного 422, один и тот же кириллический файл
превращается в два вложения, при пустом `SDLC_HUB_HOME` данные пишутся в рабочий каталог процесса, после прерванной
публикации агента может потеряться прежняя версия. Владелец с ними не сталкивался и не хочет столкнуться. Это пакет C
сверки (sdlc-hub#129).
## What Changes
- **Параметры команд** (#111 T-F1, #49 M-4): при постановке пакета хаб приводит параметры к каноническому виду.
Идентификаторы (ключи, имена спринтов, тип связи, логин, даты, маркер комментария, id вложения) и элементы списков
обрезаются по краям. `filename` у `ensure_attachment` обрезается и приводится к NFC, в том числе имя по умолчанию из
пути в MCP. Текст (`summary`, `description`, `body`, `goal`) хранится как задан.
- **Цифры — только ASCII** (#86 F.1): `attachment_id`, id вложений в состоянии агента, версия агента — только `0-9`.
Сейчас `"²"` проходит `isdigit()` и роняет `int()` с 500.
- **Форма тела — 422, не 500** (#42 T2.1, #94 T8.1): элемент `issues` в `POST /agent/state` должен быть объектом со
строковым `key`, `project` в `POST /wiki/sync` должен быть строкой.
- **Файлы хранилища** (#49 M-3): ошибка доступа к файлу (`PermissionError`, `IsADirectoryError` и любая `OSError`)
обрабатывается как «файла нет» с предупреждением в лог, а не 500.
- **Пустой `SDLC_HUB_HOME`** (#102 T3.2): трактуется как незаданный, каталог данных по умолчанию.
- **Публикация агента** (#39 T2.2, M-6): перед публикацией хаб восстанавливает `agent.py` из `agent.py.prev`, если
прошлая публикация прервалась; годный `agent.py.prev` не перезаписывается содержимым, не совпадающим с манифестом.
- **Уборка настроек** (#64 «выкатка»): ключи `base_jql` и `board_id` объявлены устаревшими и удаляются из `setting`
при открытии хаба, как `status_labels`/`transitions_map`.
## Non-Goals
- O-3 из закрытой #4 (`check_confirmed` и симметричные типы связей): хаб не знает, какие типы связей симметричны, это
знает только агент. Чинится в пакете B: агент передаёт симметричные типы, хаб учитывает их при подтверждении
по состоянию. До этого последствие безвредное: вместо подтверждения по состоянию команда ждёт отчёта агента (R-1).
- Изменения агентов, веба, MCP-инструментов (кроме имени вложения по умолчанию) и схемы базы.
- Прочие маркеры исходных issue остаются в своих issue.
## Impact
- Спеки: `commands` (нормализация параметров, цифровые id), `hub-api` (форма тела, файлы хранилища, публикация,
каталог данных), `data-model` (требование «Настройки»).
- Код: `modules/jira/commands.py`, `modules/jira/hooks.py`, `modules/jira/mirror.py`, `modules/jira/mcp.py`,
`modules/jira/fetch.py`, `modules/jira/attachments.py`, `modules/jira/settings.py`, `modules/wiki/api.py`,
`core/files.py`, `core/paths.py`, `core/release.py`.
- Живая база: при первом старте нового кода из `setting` уйдут `base_jql` и `board_id`. Резервная копия базы
делается штатно.
- После merge — комментарии в #111, #42, #94, #86, #49, #102, #39, #64 о закрытых маркерах; O-3 — в пакет B.

View File

@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: Нормализация параметров при постановке
При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в
каноническом виде, после всех проверок валидации. Строковые параметры обрезаются по краям, кроме текстовых `summary`,
`description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove`
и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и
`null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC.
Агент SHALL получать параметры в том виде, в каком они сохранены.
#### Scenario: Ключ с пробелами
- **WHEN** `POST /batches` с `ensure_rank {key: "A-1", before: " A-2 "}`
- **THEN** пакет создан, параметры команды `{key: "A-1", before: "A-2"}`; агент при `claim` получает `before = "A-2"`
#### Scenario: Текст не обрезается
- **WHEN** `POST /batches` с `ensure_comment {key: " A-1", marker: "Итог ", body: " отступ\n"}`
- **THEN** сохранено `key = "A-1"`, `marker = "Итог"`, `body = " отступ\n"`
#### Scenario: Имя вложения в NFD
- **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308)
- **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку
`filename`
### Requirement: Идентификаторы из цифр — только ASCII
Хаб SHALL считать строку числовым идентификатором (`attachment_id` в `fetch_attachment`, `id` вложения в состоянии
агента, ссылка `#<id>` в MCP, версия агента), только если она непуста и состоит из символов `0-9`. Строка из других
цифр Unicode (`"²"`, `"١٢"`) — не идентификатор: при постановке 422, в состоянии агента 422 `bad_state`, в MCP —
поиск по имени. Ответ 500 MUST NOT возникать.
#### Scenario: Надстрочная цифра
- **WHEN** `POST /batches` с `fetch_attachment {key: "A-1", attachment_id: "²"}`
- **THEN** 422 «attachment_id — цифры id вложения из issue», пакет не создан

View File

@ -0,0 +1,56 @@
## MODIFIED Requirements
### Requirement: Настройки
Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
`pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`;
относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию
`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением
по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование
«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Ключи, которые модуль объявил устаревшими (у jira — `status_labels`, `transitions_map`, оставшиеся от v3
`base_jql` и `board_id`), хаб SHALL удалять при каждом заполнении умолчаний, не меняя `settings_version`; агенту они
не передаются. `GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через
`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json`
командой `sdlc-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** `sdlc-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` с обеими метками
#### Scenario: Ключи вернул процесс прежней версии
- **WHEN** база v8, процесс со старым кодом (MCP-сессия до reconnect) вставил `status_labels = {}` и
`transitions_map = {}`, затем вызван `ensure_defaults` текущего кода (старт хаба, новая MCP-сессия)
- **THEN** ключей в `setting` нет, `settings_version` прежний
#### Scenario: Ключей статуса по факту нет
- **WHEN** хаб открыл базу v8
- **THEN** `GET /settings` не содержит `status_labels` и `transitions_map`; `GET /agent/settings` агенту `jira` их не
отдаёт
#### Scenario: Ключи v3 в живой базе
- **WHEN** в `setting` лежат `base_jql` и `board_id` (база, поднятая с v3), хаб открыл базу
- **THEN** ключей `base_jql` и `board_id` в `setting` нет, `settings_version` прежний; `GET /settings` отдаёт вычисленную
`base_jql`, `board_id` в ответе нет; `PUT /settings {board_id: 1}` — 422 «доска Jira — свойство проекта»

View File

@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Форма тела запроса
На тело неверной формы хаб SHALL отвечать 422, а не 500. В `POST /agent/state` поле `issues` SHALL быть списком,
каждый его элемент — объектом с непустым строковым `key`. Иначе 422 `bad_state` с номером элемента, и пачка не
применяется. В `POST /wiki/sync` поле `project` в теле, если задано, SHALL быть строкой. Иначе 422 `bad_request`
«project — строка».
#### Scenario: Элемент состояния не объект
- **WHEN** агент шлёт `POST /agent/state {issues: ["A-1"]}`
- **THEN** 422 `bad_state` «issues[0] — объект задачи с key», зеркало не изменилось
#### Scenario: Проект вики не строкой
- **WHEN** `POST /wiki/sync {project: 5}`
- **THEN** 422 `bad_request` «project — строка», пакет не создан
### Requirement: Ошибка доступа к файлу хранилища
Если файл хранилища вложений не читается (`PermissionError`, `IsADirectoryError` или другая ошибка ОС), хаб SHALL
обрабатывать его как отсутствующий: при `claim` `ensure_attachment` получает `size = null`, `GET /files/<sha>` и
скачивание вложения отвечают 404, как для удалённого файла. В лог хаба SHALL писаться предупреждение с путём и
ошибкой. `FileNotFoundError` в лог не пишется.
#### Scenario: Нет прав на файл
- **WHEN** у файла `files/<sha>` сняты права на чтение, агент берёт `ensure_attachment` с этим `file`
- **THEN** `claim` отвечает 200, у команды `size = null`; в логе — предупреждение с путём
### Requirement: Пустой SDLC_HUB_HOME
Переменная `SDLC_HUB_HOME`, заданная пустой строкой, SHALL трактоваться как незаданная: каталог данных —
`~/.local/share/sdlc-hub/`, проверка прежнего каталога выполняется. Хаб MUST NOT писать данные в рабочий каталог
процесса из-за пустой переменной.
#### Scenario: Пустая переменная
- **WHEN** `SDLC_HUB_HOME= sdlc-hub tokens`
- **THEN** база открыта из `~/.local/share/sdlc-hub/hub.sqlite`; в рабочем каталоге не появился `hub.sqlite`
### Requirement: Восстановление прерванной публикации агента
Перед публикацией версии агента модуля хаб SHALL приводить `release/<модуль>/` к опубликованному состоянию по
`manifest.json`. Если `agent.py.prev` есть, а `agent.py` нет или не совпадает по sha256 с манифестом, тогда как
`agent.py.prev` совпадает, `agent.py.prev` SHALL возвращаться на место `agent.py`. Если `agent.py` совпадает с
манифестом, оставшийся `agent.py.prev` удаляется. В других сочетаниях файлы не трогаются, в лог пишется
предупреждение. Годный `agent.py.prev`, совпадающий с манифестом, MUST NOT перезаписываться другим содержимым.
#### Scenario: Сбой между переименованием и записью
- **WHEN** опубликована v5, прошлая публикация упала после переименования: `agent.py` нет, `agent.py.prev` — v5,
манифест — v5; новая публикация v6 снова падает при записи
- **THEN** в `release/jira/` — `agent.py` v5 и манифест v5, опубликованной остаётся v5
#### Scenario: Сбой поверх неудачного возврата
- **WHEN** `agent.py` — недописанный v6, `agent.py.prev` — v5, манифест — v5; запущена публикация v6
- **THEN** перед записью `agent.py` восстановлен из `.prev` (v5); при сбое записи опубликованной остаётся v5, а `.prev`
не затёрт недописанным файлом