21 KiB
Пакет 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
- Временный ключ с пробелами (
epic_key: " new:1 ",keys: [" new:1"]вrefresh) должен подставляться так же, как без пробелов. Тест в Task 1. - Повтор команды (
POST /commands/{id}/retry) и пакет, поставленный через MCPbatch_create, должны получать те же нормализованные параметры. Тест на MCP в Task 1 (имя вложения по умолчанию из пути в NFD). - Нестроковые значения там, где ждут цифры (
id: 7числом в состоянии агента,attachment_id: 7), — 422, а не 500. Тест в Task 2. issuesне списком ({"issues": {"key": "A"}},{"issues": "A-1"}) вPOST /agent/state— 422, а не 500 на итерации. Тест в Task 3.- Первая публикация после сбоя, когда манифеста ещё нет (
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": "²", …}→ 422bad_state; с{"id": 7, …}(число) → 422bad_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(monkeypatchPath.stat/Path.read_bytesбросаетPermissionError(13, "Permission denied")) →None, вcaplogпредупреждение с путём; приIsADirectoryError(на месте файла каталогroot/<sha>) →None;FileNotFoundError→Noneбез записи в лог. -
test_api.py: файлensure_attachmentзаменён каталогом →claim200,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)"