# Пакет 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)" ```