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