sdlc-hub/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md

312 lines
21 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.

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