sdlc-hub/docs/superpowers/plans/2026-10-27-wiki-package-d.md
Andrey Epifancev a83dd27733 План: ветка пакета D (#135)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:39:13 +04:00

25 KiB
Raw Blame History

Пакет D модуля wiki — план реализации

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: закрыть маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 и показать скачанные картинки в тексте страницы «Вики» (sdlc-hub#135).

Architecture: точечные правки модуля wiki: перевод HTML (convert.py), постановка wiki_sync (commands.py), вложения (attachments.py, api.py), MCP (mcp.py); во фронте — режим «показать» у скачивания вложения и хранилище картинок страницы (download.ts, PageView.tsx). Схема базы и контракт с агентом не меняются.

Tech Stack: Python 3.13 (uv), pytest, BeautifulSoup + markdownify; React 19 + TypeScript, Vitest, Testing Library.

Ветка: feat/135-wiki-package-d (worktree .wt/wiki-d).

Spec: openspec/changes/wiki-package-d/ (proposal.md, design.md — решения 1–10, specs/wiki, specs/mcp, specs/web-app).

Global Constraints

  • Только хаб sdlc-hub; eva-agent и jira-agent не меняются, SCHEMA_VERSION не меняется.
  • Граница ядра: в src/sdlc_hub/core/ правок нет (проверяют tests/test_registry.py).
  • Тексты ошибок и комментарии — по-русски, в стиле соседнего кода.
  • Расширения картинок: .png .jpg .jpeg .gif .webp .bmp без учёта регистра; SVG — не картинка.
  • Пробел → %20, ( → %28, ) → %29 — только во внешних адресах ссылок и картинок.
  • Зелёным считается только весь набор: в конце make check (pytest + lint и тесты фронта).
  • Соединения SQLite в тестах — через фикстуры open_db/conn, иначе явный close().

Review Focus

  1. Ссылка на страницу, внутри которой и картинка-вложение, и текст (<a href="project/Document/DOC-1"><img src=…>Схема</a>) — вложение перед ссылкой, текст ссылки «Схема». Тест в Task 1.
  2. Внешняя ссылка с уже закодированным адресом (%20 в href) не кодируется повторно (%2520). Тест в Task 1.
  3. Повтор упавшей синхронизации, когда идущих синхронизаций нет, проходит как раньше. Тест в Task 2.
  4. Вложение ready с файлом на месте — fetch отвечает 200 ready без нового пакета (T8.2 не ломает обычный путь). Тест в Task 3.
  5. Смена страницы, пока картинка грузится: поздний ответ content не кладёт картинку на новую страницу, objectURL старой отозваны. Тест в Task 5.

Task 1: Перевод HTML — T6.1–T6.5

Files:

  • Modify: src/sdlc_hub/modules/wiki/convert.py (_Converter.__init__, convert_img, convert_a, _label, новые _href, _split_marks)
  • Test: tests/test_wiki_convert.py

Interfaces:

  • Produces: convert.to_markdown(html, attachments, links) -> (str, list[dict]) — сигнатура прежняя.

  • Step 1: Написать падающие тесты (константы FILE_REF, THUMB_REF, IMG_REF, FILE_URL уже в файле; IMG_URL = "https://eva.example.test" + IMG_REF, FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}, THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}, LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"})

def test_thumbnail_in_wrapper_keeps_link():                       # T6.1
    md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [THUMB, FILE], [])
    assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1

def test_other_attachment_inside_attachment_link():               # T6.1, design 1
    md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [FILE, IMG], [])
    assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n"
    assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"]

def test_attachment_inside_page_link():                          # T6.2
    md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [IMG], [LINK])
    assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n"

def test_attachment_and_text_inside_page_link():                 # Review Focus 1
    md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [IMG], [LINK])
    assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n"

def test_attachment_inside_external_link():                      # design 2
    md, _ = convert.to_markdown(f'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [IMG], [])
    assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n"

def test_duplicate_ref_first_wins():                             # T6.3
    md, atts = convert.to_markdown(f'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
    assert atts == [{"url": IMG_URL, "name": "first"}]
    md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK, dict(LINK, code="DOC-9")])
    assert "(page:DOC-1)" in md

def test_external_href_spaces_and_parens_encoded():              # T6.4
    md, _ = convert.to_markdown('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
    assert md == "[док](http://ex.com/a%20b%28c%29.html)\n"

def test_external_href_already_encoded_untouched():              # Review Focus 2
    md, _ = convert.to_markdown('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
    assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md

def test_trailing_backslash_in_page_link_text():                 # T6.5
    md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [LINK])
    assert md == "[путь C:\\\\](page:DOC-1)\n"                    # в Markdown: [путь C:\\](page:DOC-1)

def test_trailing_backslash_in_external_link_text():             # T6.5
    md, _ = convert.to_markdown('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
    assert md == "[путь C:\\\\](http://ex.com/)\n"

Если markdownify уже экранирует \ в тексте, удвоение в _label не нужно — решают тесты.

  • Step 2: Запустить — тесты падают

Run: uv run pytest tests/test_wiki_convert.py -q Expected: новые тесты FAIL, прежние PASS.

  • Step 3: Реализовать в convert.py

    • _by_ref, _links — первая запись по ref (setdefault в цикле).
    • convert_img: предок — el.find_parent("a"); если его href в _by_ref и (src не в _by_ref или url записи src == url записи href) — "".
    • _split_marks(text: str) -> tuple[str, str] — (все фрагменты [📎 …<маркер> подряд, остаток текста без них); фрагмент вложения — "[📎 " + имя + _MARK_OPEN + n + _MARK_CLOSE, искать регуляркой до _MARK_CLOSE.
    • convert_a: для вложения — marks + self._attachment(entry) (маркеры из text сохраняются перед своей ссылкой); для ссылки на страницу — marks + (" " if marks else "") + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code); для внешней — то же разбиение, ссылка через super().convert_a с el["href"] = _href(href) и текстом-остатком (пустой — адрес).
    • convert_img для внешней картинки — el["src"] = _href(src) перед super().convert_img.
    • _href(url: str) -> str: url.replace(" ", "%20").replace("(", "%28").replace(")", "%29").
    • _label: нечётная серия \ в конце строки удваивается до чётной.
  • Step 4: Запустить — всё зелёное

Run: uv run pytest tests/test_wiki_convert.py tests/test_wiki_sync.py -q Expected: PASS.

  • Step 5: Коммит
git add src/sdlc_hub/modules/wiki/convert.py tests/test_wiki_convert.py
git commit -m "Вики: перевод HTML — миниатюра в обёртке, вложение в ссылке, кодирование адресов (#135, T6.1–T6.5)"

Task 2: Одна синхронизация на корень — T8.3

Files:

  • Modify: src/sdlc_hub/modules/wiki/commands.py (check_batch, enqueue_sync, новая _active_same_root)
  • Test: tests/test_wiki_commands.py, tests/test_wiki_projects.py

Interfaces:

  • Produces: commands._active_same_root(conn, label: str, root: str) -> int | None — id пакета с wiki_sync проекта в queued/claimed и params.root == root (по json_extract(c.params, '$.root')), самый новый.

  • Produces: commands.enqueue_sync(conn, label, root, author) -> dict — {id, estimate_seconds, count}; при идущей синхронизации того же корня — её пакет (estimate_seconds из batch, count — число команд пакета).

  • Step 1: Написать падающие тесты

# tests/test_wiki_commands.py
SYNC = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}]

def test_check_batch_refuses_second_sync_same_root(conn):
    b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
    with pytest.raises(CommandError, match=f"#{b['id']}"):
        queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")

def test_check_batch_other_root_allowed(conn):
    queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
    other = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-2"}}]
    assert queue.create_batch(conn, "t", "u", other, project="AWPCA")["count"] == 1

def test_retry_failed_sync_without_active_passes(conn):          # Review Focus 3
    b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
    conn.execute("UPDATE command SET status = 'failed' WHERE batch_id = ?", (b["id"],))
    assert queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")["count"] == 1

def test_enqueue_sync_returns_active_same_root(conn):
    _root(conn)
    first = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
    again = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
    assert again["id"] == first["id"] and again["count"] == 1 and _count(conn) == 1
# tests/test_wiki_projects.py
def test_root_back_while_old_claimed(hub_server):              # A → B → A
    c = hub_server["client"]
    c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"})
    conn = hub_server["hub"].conn()
    try:
        conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'")
    finally:
        conn.close()
    c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
    c.patch("/projects/AWPCA", {"wiki_root": "DOC-12"})            # не 422
    assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("cancelled", "DOC-21")]

def test_retry_while_sync_running_refused(hub_server):           # сценарий spec «Повтор упавшей синхронизации…»
    c = hub_server["client"]
    c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
    conn = hub_server["hub"].conn()
    try:
        failed = conn.execute("SELECT id FROM command WHERE type = 'wiki_sync'").fetchone()["id"]
        conn.execute("UPDATE command SET status = 'failed' WHERE id = ?", (failed,))
    finally:
        conn.close()
    running = c.post("/wiki/sync", {"project": "AWPCA"})["batch_id"]
    with pytest.raises(HubError) as e:
        c.post(f"/commands/{failed}/retry", {})
    assert e.value.status == 422 and f"#{running}" in str(e.value)

Статус отменённой команды в _syncs — каким его пишет queue.cancel_batch (исполнитель сверяет по test_patch_root_cancels_queued и подставляет фактический). Атрибуты HubError (status, текст) — как в соседних тестах файла.

  • Step 2: Запустить — тесты падают

Run: uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py -q Expected: новые FAIL.

  • Step 3: Реализовать _active_same_root, проверку в check_batch и поиск в enqueue_sync

Текст ошибки check_batch: f"команда {i}: у проекта {project} уже идёт синхронизация корня {root} (пакет #{bid})". enqueue_sync и check_batch меняются в одном коммите (design 7).

  • Step 4: Запустить wiki-тесты

Run: uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py tests/test_wiki_api.py tests/test_wiki_sync.py -q Expected: PASS.

  • Step 5: Коммит
git add src/sdlc_hub/modules/wiki/commands.py tests/test_wiki_commands.py tests/test_wiki_projects.py
git commit -m "Вики: одна синхронизация на корень — повтор не ставит вторую (#135, T8.3)"

Task 3: Вложение без файла и признак картинки — T8.2, image

Files:

  • Modify: src/sdlc_hub/modules/wiki/attachments.py (request_fetch, новая is_image), src/sdlc_hub/modules/wiki/api.py:77-79 (страница), :125 (fetch)
  • Test: tests/test_wiki_api.py

Interfaces:

  • Produces: attachments.request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict — новый обязательный параметр files_dir (путь или None; None — проверка файла пропускается).

  • Produces: attachments.is_image(url: str | None) -> bool — последний сегмент urlsplit(url).path, unquote, .lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")).

  • Produces: GET /wiki/pages/{code} → attachments: [{id, name, state, size, error, image}] (Task 5 читает image).

  • Step 1: Написать падающие тесты

def test_fetch_ready_file_missing_requeues(api):                 # T8.2
    aid = synced(api)
    api.client.post(f"/wiki/attachments/{aid}/fetch", {})
    sha = upload(api, b"hello")["sha256"]
    api.report(fetch_cmd(api), "done", {"file": sha, "size": 5})
    (api.files_dir / sha[:2] / sha).unlink()                     # путь файла — как в core.files; сверить
    r = api.client.post(f"/wiki/attachments/{aid}/fetch", {})
    assert r["state"] == "fetching"
    assert fetch_cmd(api)["params"]["attachment_id"] == aid

def test_fetch_ready_file_present_no_batch(api):                 # Review Focus 4
    aid = synced(api)
    api.client.post(f"/wiki/attachments/{aid}/fetch", {})
    sha = upload(api, b"hello")["sha256"]
    api.report(fetch_cmd(api), "done", {"file": sha, "size": 5})
    n = len(api.rows("SELECT id FROM batch"))
    assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "ready"
    assert len(api.rows("SELECT id FROM batch")) == n

def test_page_attachment_image_flag(api):
    synced(api, attachments=[{"ref": FILE_REF, "url": FILE_URL, "name": "attachment"},
                             {"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "Схема без расширения"}])
    atts = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"]
    assert [a["image"] for a in atts] == [False, True]

@pytest.mark.parametrize("url,expected", [("https://e/files/a/x.PNG?version=1", True), ("https://e/files/a/x.svg", False),
                                          ("https://e/files/a/%D1%81.jpeg", True), (None, False), ("https://e/files/a/x.png.meta/", False)])
def test_is_image(url, expected):
    assert attachments.is_image(url) is expected

Ключ ответа POST /agent/files, путь файла в files/ и доступ к files_dir в фикстуре api — по core/files.py и tests/test_wiki_sync.py; исполнитель подставляет фактические. HTML страницы в test_page_attachment_image_flag должен содержать оба ref (<a href=FILE_REF> и <img src=IMG_REF>) — передать name_html.

  • Step 2: Запустить — тесты падают

Run: uv run pytest tests/test_wiki_api.py -q Expected: новые FAIL.

  • Step 3: Реализовать

    • request_fetch: ветка «ответить как есть» для ready — только если files_dir is None or files.size(files_dir, row["file"]) is not None.
    • h_attachment_fetch передаёт hub.files_dir; прочие вызовы request_fetch (grep) — тоже.
    • Страница: в выборке вложений добавить url, в ответ — image: attachments.is_image(url), url в ответ страницы не добавлять.
  • Step 4: Запустить wiki-тесты

Run: uv run pytest tests/test_wiki_api.py tests/test_wiki_mcp.py -q Expected: PASS (прежний test_status_tree_page обновить: в ожидаемом словаре вложения — "image": False).

  • Step 5: Коммит
git add src/sdlc_hub/modules/wiki/attachments.py src/sdlc_hub/modules/wiki/api.py tests/test_wiki_api.py
git commit -m "Вики: вложение без файла скачивается заново, признак картинки у вложений страницы (#135, T8.2)"

Task 4: MCP — значения из Eva одной строкой (T9.3)

Files:

  • Modify: src/sdlc_hub/modules/wiki/mcp.py (новая _line, места вывода из design 8)
  • Test: tests/test_wiki_mcp.py

Interfaces:

  • Produces: mcp._line(v) -> str — core.value(v), затем re.sub(r"\s*[\r\n]+\s*", " ", …).

  • Step 1: Написать падающие тесты (маршруты Fake — как в соседних тестах test_page_chunks, test_search, test_tree_depth_and_subtree)

def test_page_header_newlines_collapsed(hub):
    hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\nСтатусы",
                               "url": "https://eva/x", "synced_at": "2026-10-24T10:00:00+00:00", "markdown": "строка1\nстрока2",
                               "attachments": [{"id": 7, "name": "a\nb.png", "state": "none", "size": None, "error": None, "image": True}]}})
    out = asyncio.run(...)  # вызов page как в test_page_chunks
    lines = out.splitlines()
    assert lines[0] == "DOC-3 — Статусы путь: поддельный"
    assert sum(l.startswith("путь:") for l in lines) == 1
    assert "  #7 a b.png (none)" in lines and "строка1\nстрока2" in out

def test_search_and_tree_newlines_collapsed(hub):
    # search: title "A\nB", path "P\nQ", snippet "x\ny" → одна строка «… A B — P Q» и строка «  x y»
    # tree: title "T\nU" → «DOC-2 T U (200 симв.)»

Исполнитель дописывает второй тест конкретными Fake-маршрутами и утверждениями из комментария — значения заданы.

  • Step 2: Запустить — тесты падают

Run: uv run pytest tests/test_wiki_mcp.py -q Expected: новые FAIL.

  • Step 3: Реализовать _line и применить — tree, page (шапка и вложения), _candidates (названия и title запроса), search, attachment (имя, код страницы), _summary/status (название корня, ошибка). Тело страницы не трогать.

  • Step 4: Запустить

Run: uv run pytest tests/test_wiki_mcp.py -q Expected: PASS.

  • Step 5: Коммит
git add src/sdlc_hub/modules/wiki/mcp.py tests/test_wiki_mcp.py
git commit -m "MCP вики: значения из Eva одной строкой (#135, T9.3)"

Task 5: Картинки в тексте страницы «Вики»

Files:

  • Modify: web/src/modules/wiki/api/queries.ts (WikiAttachment.image), web/src/modules/wiki/download.ts (режим show, хранилище картинок), web/src/modules/wiki/PageView.tsx (AttachmentLink), web/src/modules/wiki/wiki.css
  • Test: web/src/modules/wiki/download.test.tsx, web/src/modules/wiki/WikiPage.test.tsx

Interfaces:

  • Consumes: GET /wiki/pages/{code} → attachments[].image: boolean (Task 3).

  • Produces: useWikiDownload(project, scope, images?: WikiAttachment[]) → { start(id: string, mode?: "save" | "show"): void; status(id): DownloadStatus; image(id): string | null }; image — objectURL картинки или null. Объект стабилен при неизменных start/status/image (как сейчас).

  • Тип blob: new Blob([blob], { type: MIME_BY_EXT[ext] ?? "application/octet-stream" }), MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" }; расширение — из name, если его нет — image/* не ставится, браузер определит сам (blob без типа).

  • Step 1: Написать падающие тесты (моки api и URL.createObjectURL/revokeObjectURL — как в download.test.tsx)

it("ready-картинка при открытии страницы показывается без fetch", async () => {
  // страница DOC-3, attachments: [{id: 7, name: "scheme.png", state: "ready", image: true}]
  // ожидание: GET /wiki/attachments/7/content вызван, POST …/fetch — нет; в DOM img с src blob:… и alt «scheme.png»;
  // под картинкой кнопка «scheme.png» (сохранение)
});
it("клик по нескачанной картинке: fetch, опрос, картинка вместо сохранения", async () => {
  // state none → клик → POST /wiki/attachments/7/fetch → ready → GET content; img в DOM; save (a.download) не вызван
});
it("не картинка сохраняется как раньше", async () => {
  // {id: 8, name: "model.xlsx", state: "ready", image: false} → клик → a.download === "model.xlsx", img нет
});
it("content 409 у ready-картинки — остаётся ссылка без ошибки, клик ставит скачивание", async () => {});
it("смена страницы: поздний content не показывается, objectURL отозваны", async () => {   // Review Focus 5
});
it("появление картинки не сбрасывает busy другого вложения", async () => {});

Исполнитель дописывает тела по образцу соседних тестов download.test.tsx/WikiPage.test.tsx; ожидания заданы в комментариях.

  • Step 2: Запустить — тесты падают

Run: cd web && npx vitest run src/modules/wiki Expected: новые FAIL.

  • Step 3: Реализовать по design 10: автозапрос content один раз на ready-картинку за жизнь scope; режим show кладёт objectURL в ту же карту; сброс карты и revokeObjectURL при смене project/scope и размонтировании; AttachmentLink — <span className="wiki-att-img"><img src alt={name} /></span> и кнопка save под ней, без картинки — кнопка show для image, save иначе. CSS: .wiki-att-img{display:block} и .wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)}.

  • Step 4: Запустить фронт целиком

Run: cd web && npm run lint && npm test Expected: lint чистый, все тесты PASS.

  • Step 5: Коммит
git add web/src/modules/wiki
git commit -m "Вики: скачанные картинки показываются в тексте страницы (#135)"

Завершение

  • make check — весь набор зелёный.
  • Живая проверка на тестовом хабе или после выкатки (за владельцем): картинка vgo-matching-1.png (AWPCA, в ready) видна в тексте своей страницы.