diff --git a/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md b/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md new file mode 100644 index 0000000..1c35bec --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md @@ -0,0 +1,311 @@ +# Пакет C бэкенда хаба — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** закрыть подтверждённые дефекты бэкенда хаба из пакета C (sdlc-hub#129): нормализация параметров команд, ASCII-цифры, 422 вместо 500, ошибки доступа к файлам, пустой `SDLC_HUB_HOME`, восстановление публикации агента, уборка ключей `base_jql`/`board_id`. + +**Architecture:** правки точечные, в существующих функциях. Нормализация — одна функция `commands.normalize` внутри `commands.validate` (через неё идут все пути постановки: `queue.create_batch` → `hooks.validate`). Восстановление публикации — `release._recover` в начале `release.publish`. Схема базы и контракт с агентами не меняются. + +**Tech Stack:** Python 3.13 (uv), pytest, stdlib (`unicodedata`, `http.server`, `sqlite3`). + +**Spec:** `openspec/changes/hub-backend-fixes/` (proposal.md, design.md — решения 1–10, specs/commands, specs/hub-api, specs/data-model). + +## Global Constraints + +- Только хаб (`src/sdlc_hub/`, `tests/`); агенты, веб (`web/`) и схема базы (`SCHEMA_VERSION = 9`) не меняются. +- Граница ядра: новые имена в `core/` без терминов модулей (`jira`, `wiki`, `eva`); проверяют `tests/test_registry.py`. +- Тексты ошибок — по-русски, в стиле существующих: «команда {i} ({ctype}): нет …; ожидаются …». +- Текстовые параметры `summary`, `description`, `body`, `goal` не обрезаются (design R-2). +- `uv run pytest` — зелёным считается только весь набор; в конце — `make check`. +- Соединения SQLite в тестах — через фикстуры `open_db`/`conn`, иначе явный `close()`. + +## Review Focus + +1. Временный ключ с пробелами (`epic_key: " new:1 "`, `keys: [" new:1"]` в `refresh`) должен подставляться так же, как без пробелов. Тест в Task 1. +2. Повтор команды (`POST /commands/{id}/retry`) и пакет, поставленный через MCP `batch_create`, должны получать те же нормализованные параметры. Тест на MCP в Task 1 (имя вложения по умолчанию из пути в NFD). +3. Нестроковые значения там, где ждут цифры (`id: 7` числом в состоянии агента, `attachment_id: 7`), — 422, а не 500. Тест в Task 2. +4. `issues` не списком (`{"issues": {"key": "A"}}`, `{"issues": "A-1"}`) в `POST /agent/state` — 422, а не 500 на итерации. Тест в Task 3. +5. Первая публикация после сбоя, когда манифеста ещё нет (`agent.py.prev` есть, `manifest.json` нет), — ничего не трогать, публикация идёт как первая. Тест в Task 5. + +--- + +### Task 1: Нормализация параметров команд (#111 T-F1, #49 M-4) + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/commands.py` (`validate`, новая `normalize`) +- Modify: `src/sdlc_hub/modules/jira/mcp.py:351` (поиск вложения по имени), `:481` (имя по умолчанию из пути) +- Test: `tests/test_commands.py`, `tests/test_api.py`, `tests/test_mcp.py` + +**Interfaces:** +- Produces: `commands.normalize(ctype: str, params: dict) -> dict` — новый словарь; строки обрезаны, кроме `TEXT_PARAMS = {"summary", "description", "body", "goal"}`; элементы `LIST_PARAMS` и `target`-списка обрезаны; `filename` — `unicodedata.normalize("NFC", s.strip())`; нестроковые значения как есть. + +- [ ] **Step 1: Падающие тесты в `tests/test_commands.py`** + +```python +def test_normalize_trims_identifiers_keeps_text(): + out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}]) + assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"} + +def test_normalize_rank_before(): + out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}]) + assert out[0]["params"] == {"key": "A-1", "before": "A-2"} + +def test_normalize_temp_key_with_spaces(): + out = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}}, + {"type": "refresh", "params": {"keys": [" new:1"]}}]) + assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"] + +def test_normalize_blank_required_is_missing(): + with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"): + commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}]) + +def test_normalize_blank_list_item(): + with pytest.raises(CommandError, match="labels_add — без пустых элементов"): + commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}]) + +def test_normalize_filename_nfc(): + nfd = unicodedata.normalize("NFD", "отчёт.pdf") + out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}]) + assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf") + +def test_normalize_does_not_mutate_input(): + params = {"key": " A-1 ", "target": [" В работе ", "Готово "]} + out = commands.validate([{"type": "ensure_status", "params": params}]) + assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"] +``` + +В `tests/test_api.py` — через живой API: `POST /batches` с `ensure_rank {key: "X-1", before: " X-2 "}` (задачи в зеркале), затем `claim` агентом отдаёт `params["before"] == "X-2"`. + +В `tests/test_mcp.py` — MCP `batch_create` с `ensure_attachment {key, path}` к файлу, чьё имя записано в NFD: в поставленной команде `filename` в NFC. MCP-инструмент `attachment` по имени в NFC находит вложение, у которого в зеркале `filename` в NFD (и наоборот). + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_commands.py -k normalize -v` +Expected: FAIL (параметры не обрезаны, `filename` не в NFC) + +- [ ] **Step 3: Реализовать `normalize` и встроить в `validate`** + +Порядок в `validate` (design #1): после проверки типов значений (`bad = …`) — `params = normalize(ctype, params)`. Затем повторная проверка обязательных параметров по нормализованным значениям, тем же текстом «нет …; ожидаются …». Затем проверка пустых элементов списков «<параметр> — без пустых элементов». Дальше всё как было (`_rule_error`, ensure_fields без полей, temp_key, временные ключи, повтор темы); в `out` кладётся нормализованный `params`. В `mcp.py:351` сравнивать `unicodedata.normalize("NFC", …)` обеих сторон. В `mcp.py:481` имя по умолчанию тоже привести к NFC; оно всё равно пройдёт `validate`, но в тексте ответа MCP должно быть то же имя. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest tests/test_commands.py tests/test_api.py tests/test_mcp.py -q` и `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/sdlc_hub/modules/jira/commands.py src/sdlc_hub/modules/jira/mcp.py tests/test_commands.py tests/test_api.py tests/test_mcp.py +git commit -m "Команды: параметры обрезаются при постановке, имя вложения — NFC (#129, #111 T-F1, #49 M-4)" +``` + +### Task 2: Идентификаторы из цифр — только ASCII (#86 F.1) + +**Files:** +- Modify: `src/sdlc_hub/core/http.py` (новая `is_digits` рядом с `int_group`) +- Modify: `src/sdlc_hub/modules/jira/hooks.py:43`, `mirror.py:46`, `attachments.py:43`, `fetch.py:34`, `mcp.py:348`, `history.py:17`, `mcp.py:48`, `mcp.py:220` +- Test: `tests/test_commands.py` или `tests/test_api.py`, `tests/test_mirror.py`, `tests/test_attachments.py` + +**Interfaces:** +- Produces: `core.http.is_digits(value) -> bool` — `True` только для непустой `str` из `0-9` (`isinstance(value, str) and value.isascii() and value.isdigit()`); для не-строк `False`. + +- [ ] **Step 1: Падающие тесты** + +- `is_digits`: `"123"` → True; `""`, `"²"`, `"١٢"`, `"12a"`, `7`, `None` → False. +- `POST /batches` с `fetch_attachment {key: "X-1", attachment_id: "²"}` → 422 «attachment_id — цифры id вложения из issue», пакет не создан. +- `POST /agent/state` с вложением `{"id": "²", …}` → 422 `bad_state`; с `{"id": 7, …}` (число) → 422 `bad_state`. +- `attachments` сортировка (`attachments.py:43`) с `attachment_id = "²"` не бросает, такой id уходит в хвост (как нецифровой). +- `fetch.py:34`: версия агента `"²"` трактуется как неизвестная и не блокирует, без исключения. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_api.py tests/test_mirror.py tests/test_attachments.py -k "digit or ascii" -v` +Expected: FAIL (500 / ValueError от `int("²")`) + +- [ ] **Step 3: Добавить `is_digits` и заменить им `str.isdigit()` во всех перечисленных местах** + +В `fetch.py:34` — `is_digits(str(v))`; в разборе дат (`history.py:17`, `mcp.py:48`, `mcp.py:220`) — `is_digits` вместо `.isdigit()` у среза. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Цифровые id и версия агента — только ASCII 0-9, без 500 на «²» (#129, #86 F.1)" +``` + +### Task 3: Форма тела запроса — 422 (#42 T2.1, #94 T8.1) + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mirror.py` (новая `check_issues`), `src/sdlc_hub/modules/jira/api.py:23-31` (`h_agent_state`), `src/sdlc_hub/modules/wiki/api.py:96-107` (`h_sync`) +- Test: `tests/test_api.py`, `tests/test_wiki_api.py` + +**Interfaces:** +- Produces: `mirror.check_issues(issues) -> None` — `ValueError("issues — список задач")`, если не список; `ValueError(f"issues[{n}] — объект задачи с key")`, если элемент не `dict` или `key` не непустая строка. + +- [ ] **Step 1: Падающие тесты** + +```python +@pytest.mark.parametrize("issues, detail", [ + (["X-1"], "issues[0] — объект задачи с key"), + ([{"summary": "без ключа"}], "issues[0] — объект задачи с key"), + ([{"key": 5}], "issues[0] — объект задачи с key"), + ({"key": "X-1"}, "issues — список задач"), + ("X-1", "issues — список задач"), +]) +def test_agent_state_bad_issue_shape_422(hub, issues, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": issues, "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail +``` + +В `tests/test_wiki_api.py`: `err(api.client.post, "/wiki/sync", {"project": 5})` → `status == 422`, `error == "bad_request"`, `detail == "project — строка"`; пакета не создано. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_api.py -k bad_issue_shape tests/test_wiki_api.py -k "project" -v` +Expected: FAIL (500) + +- [ ] **Step 3: Реализовать** + +`check_issues(issues)` вызывается первой в блоке `try` `h_agent_state`; `issues = body.get("issues") or []` не меняется. В `h_sync` проверить `body.get("project")`: если задан и не `str`, то `ApiError(422, "bad_request", "project — строка")` до `_project`. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "API: состояние агента и синхронизация вики неверной формы — 422, а не 500 (#129, #42 T2.1, #94 T8.1)" +``` + +### Task 4: Файлы хранилища и пустой SDLC_HUB_HOME (#49 M-3, #102 T3.2) + +**Files:** +- Modify: `src/sdlc_hub/core/files.py:43-59` (`size`, `read`), `src/sdlc_hub/core/paths.py:10` (`home`) +- Test: `tests/test_files.py`, `tests/test_layout.py`, `tests/test_api.py` + +- [ ] **Step 1: Падающие тесты** + +- `test_files.py`: `size`/`read` при `PermissionError` (monkeypatch `Path.stat` / `Path.read_bytes` бросает `PermissionError(13, "Permission denied")`) → `None`, в `caplog` предупреждение с путём; при `IsADirectoryError` (на месте файла каталог `root/`) → `None`; `FileNotFoundError` → `None` без записи в лог. +- `test_api.py`: файл `ensure_attachment` заменён каталогом → `claim` 200, `params["size"] is None`. +- `test_layout.py`: + +```python +def test_empty_env_home_is_default(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.setenv("SDLC_HUB_HOME", "") + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub" +``` + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_files.py tests/test_layout.py -v` +Expected: FAIL (PermissionError наружу; `Path("")`) + +- [ ] **Step 3: Реализовать** + +`files.size`/`files.read`: `except FileNotFoundError: return None`, затем `except OSError as e: log.warning("файл хранилища %s не читается: %s", <путь>, e); return None` (логгер модуля, как в `paths.py`). `paths.home()`: `os.environ.get("SDLC_HUB_HOME") or str(Path.home() / ".local" / "share" / "sdlc-hub")`. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Хранилище: нечитаемый файл — «файла нет» с предупреждением; пустой SDLC_HUB_HOME — по умолчанию (#129, #49 M-3, #102 T3.2)" +``` + +### Task 5: Восстановление прерванной публикации агента (#39 T2.2, M-6) + +**Files:** +- Modify: `src/sdlc_hub/core/release.py` (новая `_recover`, вызов в `publish`) +- Test: `tests/test_release.py` + +**Interfaces:** +- Produces: `release._recover(rd: Path) -> None` — вызывается в `publish` сразу после `rd = Path(release_dir)`, до `current(rd)`. + +- [ ] **Step 1: Падающие тесты** (помощники `agent_file`, `_listing`, `_fail_replace_into` уже есть в файле) + +- `test_recover_missing_agent_restores_prev`: опубликована v5; руками `os.replace(rd/"agent.py", rd/"agent.py.prev")`; публикация v6 падает при записи (`monkeypatch.setattr(release.os, "fsync", no_space)`) → после неё `rd/"agent.py"` — байты v5, `release.load(rd)["version"] == 5`, `agent.py.prev` нет. +- `test_recover_partial_agent_keeps_good_prev`: опубликована v5; `agent.py.prev` = байты v5, `agent.py` = `b"AGENT_VERSION = 6\n# обрыв"`; публикация v6 падает при записи → `agent.py` = байты v5, `.prev` нет или равен v5 (не затёрт обрывком); `release.load(rd)["version"] == 5`. +- `test_recover_success_after_crash`: то же исходное состояние, публикация v6 успешна → в каталоге только `agent.py` (v6) и `manifest.json`. +- `test_recover_leftover_prev_removed`: `agent.py` совпадает с манифестом, рядом `agent.py.prev` → после успешной публикации v6 `.prev` нет (уже так), а при падении v6 до переименования `.prev`, совпадающий с манифестом, не мешает. +- `test_recover_unknown_state_untouched` (Review Focus 5): `manifest.json` нет, `agent.py.prev` есть → `_recover` ничего не трогает, в `caplog` предупреждение; публикация v1 проходит как первая. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_release.py -k recover -v` +Expected: FAIL (после сбоя `agent.py` нет / `.prev` затёрт) + +- [ ] **Step 3: Реализовать `_recover` по design #9** + +sha256 файлов сравнивать с `manifest.json` (читать его тем же способом, что `current`, без исключений наружу). Случаи: +- `.prev` совпадает с манифестом, а `agent.py` нет или не совпадает → `os.replace(prev, agent)` и `log.warning`; +- `agent.py` совпадает → лишний `.prev` удалить; +- иначе, если `.prev` есть, → `log.warning` и ничего не трогать. + +`OSError` внутри `_recover` → `log.warning`, публикация продолжается. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Публикация агента: прерванная прошлая публикация восстанавливается по манифесту (#129, #39 T2.2, M-6)" +``` + +### Task 6: Уборка ключей base_jql и board_id (#64 «выкатка») и итоговая проверка + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/settings.py:29` (`OBSOLETE`), докстринг `validate_setting` при необходимости +- Test: `tests/test_settings.py` + +- [ ] **Step 1: Падающий тест** + +```python +def test_v3_keys_removed_on_open(conn): + v0 = settings.get(conn, "settings_version") + conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')") + settings.ensure_defaults(conn) + stored = {r["key"] for r in conn.execute("SELECT key FROM setting")} + assert {"board_id", "base_jql"}.isdisjoint(stored) + assert settings.get(conn, "settings_version") == v0 + with pytest.raises(ValueError, match="доска Jira — свойство проекта"): + settings.set_(conn, "board_id", 1) +``` + +Плюс через API: `GET /settings` после открытия базы с этими ключами отдаёт вычисленную `base_jql`, а `board_id` в ответе нет. + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_settings.py -k v3_keys -v` +Expected: FAIL (ключи остались) + +- [ ] **Step 3: `OBSOLETE = ("status_labels", "transitions_map", "base_jql", "board_id")`** + +Проверить, что шаг миграции v7 → v8 (`settings.py:93-97`, использует `OBSOLETE`) от расширения кортежа не ломается: на v7-базе он удалит и эти ключи, это допустимо. Тест миграции `tests/test_db.py` должен остаться зелёным. + +- [ ] **Step 4: Итоговая проверка** + +Run: `uv run pytest -q` и `make check` +Expected: всё зелёное (pytest, lint, tsc, vitest) + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Настройки: ключи v3 base_jql и board_id удаляются при открытии хаба (#129, #64)" +``` diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md index 1e76670..9d9412a 100644 --- a/openspec/changes/hub-backend-fixes/design.md +++ b/openspec/changes/hub-backend-fixes/design.md @@ -6,9 +6,13 @@ ## Decisions -1. **Нормализация параметров — один шаг в `validate`.** После всех проверок `validate` кладёт в пакет параметры, - приведённые функцией `normalize(ctype, params) -> dict` (`modules/jira/commands.py`). Проверки на пустоту - (`.strip()`) остаются как есть. Обрезаются строковые параметры всех типов, кроме текстовых `summary`, +1. **Нормализация параметров — один шаг в `validate`.** Сразу после проверки типов значений (`_type_error`) + `validate` заменяет параметры результатом `normalize(ctype, params) -> dict` (`modules/jira/commands.py`), и все + дальнейшие проверки (обязательные параметры, `_rule_error`, временные ключи, повтор темы) идут по нормализованным + значениям. Иначе `epic_key: " new:1 "` прошёл бы как постоянный ключ и стал временным уже после проверки + объявления, а ключ из пробелов прошёл бы проверку на пустоту. Обязательный параметр, ставший пустым, даёт ту же + ошибку «нет <параметр>», пустой элемент списка — «<параметр> — без пустых элементов». Существующие `.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` тоже обрезаются. Числа, diff --git a/openspec/changes/hub-backend-fixes/specs/commands/spec.md b/openspec/changes/hub-backend-fixes/specs/commands/spec.md index 23fb45c..da7eae9 100644 --- a/openspec/changes/hub-backend-fixes/specs/commands/spec.md +++ b/openspec/changes/hub-backend-fixes/specs/commands/spec.md @@ -2,7 +2,10 @@ ### Requirement: Нормализация параметров при постановке При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в -каноническом виде, после всех проверок валидации. Строковые параметры обрезаются по краям, кроме текстовых `summary`, +каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок: +обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи +`new:` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт +422 «<параметр> — без пустых элементов». Строковые параметры обрезаются по краям, кроме текстовых `summary`, `description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove` и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и `null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC. @@ -16,6 +19,15 @@ - **WHEN** `POST /batches` с `ensure_comment {key: " A-1", marker: "Итог ", body: " отступ\n"}` - **THEN** сохранено `key = "A-1"`, `marker = "Итог"`, `body = " отступ\n"` +#### Scenario: Временный ключ с пробелами +- **WHEN** пакет `ensure_issue {temp_key: "new:1", …}`, затем `ensure_fields {key: "A-5", epic_key: " new:1 "}` +- **THEN** пакет создан, `epic_key` сохранён как `new:1` и подставляется настоящим ключом после выполнения + `ensure_issue` + +#### Scenario: Ключ из пробелов +- **WHEN** `POST /batches` с `ensure_status {key: " ", target: "Готово"}` +- **THEN** 422 «команда 1 (ensure_status): нет key; ожидаются key, target» + #### Scenario: Имя вложения в NFD - **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308) - **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку