From e786428a1d7ac7a7d7c376511f7279a8b509a94d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:06:19 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=20C=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20=D1=85=D0=B0=D0=B1=D0=B0=20hub-backend-fixes=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/hub-backend-fixes/.openspec.yaml | 2 + openspec/changes/hub-backend-fixes/design.md | 73 +++++++++++++++++++ .../changes/hub-backend-fixes/proposal.md | 45 ++++++++++++ .../hub-backend-fixes/specs/commands/spec.md | 32 ++++++++ .../specs/data-model/spec.md | 56 ++++++++++++++ .../hub-backend-fixes/specs/hub-api/spec.md | 51 +++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 openspec/changes/hub-backend-fixes/.openspec.yaml create mode 100644 openspec/changes/hub-backend-fixes/design.md create mode 100644 openspec/changes/hub-backend-fixes/proposal.md create mode 100644 openspec/changes/hub-backend-fixes/specs/commands/spec.md create mode 100644 openspec/changes/hub-backend-fixes/specs/data-model/spec.md create mode 100644 openspec/changes/hub-backend-fixes/specs/hub-api/spec.md diff --git a/openspec/changes/hub-backend-fixes/.openspec.yaml b/openspec/changes/hub-backend-fixes/.openspec.yaml new file mode 100644 index 0000000..d58ba02 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-26 diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md new file mode 100644 index 0000000..1e76670 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/design.md @@ -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` (ссылка `#`). В разборе дат `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`, и при каждом старте ключи, вставленные старым процессом, убираются снова. + Цена ошибки: нет. diff --git a/openspec/changes/hub-backend-fixes/proposal.md b/openspec/changes/hub-backend-fixes/proposal.md new file mode 100644 index 0000000..831fce2 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/proposal.md @@ -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. diff --git a/openspec/changes/hub-backend-fixes/specs/commands/spec.md b/openspec/changes/hub-backend-fixes/specs/commands/spec.md new file mode 100644 index 0000000..23fb45c --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/commands/spec.md @@ -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` вложения в состоянии +агента, ссылка `#` в 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», пакет не создан diff --git a/openspec/changes/hub-backend-fixes/specs/data-model/spec.md b/openspec/changes/hub-backend-fixes/specs/data-model/spec.md new file mode 100644 index 0000000..da751e6 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/data-model/spec.md @@ -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 — свойство проекта» diff --git a/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md b/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md new file mode 100644 index 0000000..2a60a5b --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md @@ -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/` и +скачивание вложения отвечают 404, как для удалённого файла. В лог хаба SHALL писаться предупреждение с путём и +ошибкой. `FileNotFoundError` в лог не пишется. + +#### Scenario: Нет прав на файл +- **WHEN** у файла `files/` сняты права на чтение, агент берёт `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` + не затёрт недописанным файлом