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

21 KiB
Raw Blame History

Пакет 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

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
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
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: Падающие тесты

@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
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:

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
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
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: Падающий тест

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
git commit -am "Настройки: ключи v3 base_jql и board_id удаляются при открытии хаба (#129, #64)"