diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b94510e..0346d20 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,15 +61,16 @@ | `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`, `wiki_version`, `wiki_version_stage`, `wiki_history`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib | | `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` | | `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` | -| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` | -| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | +| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии и истории при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` | +| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `deliver_params` дописывает `known_versions`, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | | `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` | | `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` | -| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` | +| `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at` | stdlib | +| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS; запись версий, «+/−» (подсчёт в `prepare`, вне транзакции), `history_at` | `core.db`, `paths`, `search`, `convert`, `history` | | `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` | | `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` | -| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | +| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | | `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | ### Контракт модуля @@ -144,7 +145,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона (новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`. -- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → отчёт `done {root_title, codes}` → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. +- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке; хаб при выдаче дописывает `known_versions`), отчёт `done {root_title, codes, versions}`; без `versions` (агент v1) история не меняется → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. - Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-/<имя>`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. @@ -154,7 +155,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - Ядро (`core/`) не импортирует модули (`sdlc_hub.modules.*`): исключения — `core/registry.py` (ленивый импорт внутри функции) и шаги истории миграций в `core/db.py` (ленивый импорт внутри шага). Проверяет `tests/test_registry.py::test_core_imports`. - Типы команд (`ensure_*`, `refresh`, `fetch_attachment`, `wiki_sync`, `wiki_fetch` и др.) и термины модулей (jira: `sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql`, поля досок; wiki: `eva`, `wiki_`, `wiki/`, `DOC-`) живут только в модулях; ядро знает их лишь через `CommandHooks` и поля проекта. Проверяют `test_core_has_no_jira_terms` (исключение — `core/db.py`, история миграций) и `test_core_has_no_module_terms` (исключение — `core/registry.py`). Меток-исключений «долг» нет. - Новые имена в ядре — без названий модулей. Модуль не вызывается из ядра иначе как через `registry` и контракт `Module`. -- Копию вики (`wiki/<метка>/`, `wiki_page`, FTS) пишет только `sync.commit` в отчёте `wiki_sync` (и удаляет при сбросе корня `projects._drop_copy` в транзакции PATCH); MCP и API её читают. Модуль wiki не обращается к Eva и не хранит токен Eva. +- Копию вики (`wiki/<метка>/`, `wiki_page`, FTS) пишет только `sync.commit` в отчёте `wiki_sync` (и удаляет при сбросе корня `projects._drop_copy` в транзакции PATCH); MCP и API её читают. Историю (`wiki_version`, `wiki_history`) пишет только `sync.commit` (удаляет — и `projects._drop_copy`). Модуль wiki не обращается к Eva и не хранит токен Eva. - Зеркало (включая `issue_attachment`) пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py index f01509a..6f12efa 100644 --- a/src/sdlc_hub/modules/wiki/commands.py +++ b/src/sdlc_hub/modules/wiki/commands.py @@ -4,7 +4,7 @@ import re from ...core import queue from ...core.errors import CommandError from ...core.modules import CommandHooks -from . import attachments, sync +from . import attachments, history, sync TYPES = {"wiki_sync", "wiki_fetch"} SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []}, @@ -113,10 +113,17 @@ def after_result(conn, cmd, status) -> None: sync.after_result(conn, cmd, status) +def deliver_params(conn, cmd, files_dir) -> dict: + """wiki_sync получает known_versions — коды уже известных хабу версий; в базе params остаются без них.""" + if cmd["type"] != "wiki_sync": + return cmd["params"] + return {**cmd["params"], "known_versions": history.known_versions(conn, cmd["params"]["label"])} + + # wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py HOOKS = CommandHooks( types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, - tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], + tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=deliver_params, temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed, diff --git a/src/sdlc_hub/modules/wiki/history.py b/src/sdlc_hub/modules/wiki/history.py index af58bfc..6244e7a 100644 --- a/src/sdlc_hub/modules/wiki/history.py +++ b/src/sdlc_hub/modules/wiki/history.py @@ -1,4 +1,5 @@ -"""История правок вики (wiki-history, #140): время версий в UTC для приёма и для хранения.""" +"""История правок вики (wiki-history, #140): время версий в UTC, выдача известных версий агенту, подсчёт «+/−».""" +import difflib from datetime import datetime, timezone @@ -14,3 +15,25 @@ def utc(value) -> str | None: if dt.tzinfo is None: return None return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + +def known_versions(conn, label: str) -> dict[str, list[str]]: + """Коды версий страниц текущей копии проекта по страницам, в порядке (created_at, code); без копии — {}.""" + out: dict[str, list[str]] = {} + for r in conn.execute( + "SELECT v.page_code, v.code FROM wiki_version v JOIN wiki_page p ON p.label = v.label AND p.code = v.page_code " + "WHERE v.label = ? ORDER BY v.page_code, v.created_at, v.code", (label,)): + out.setdefault(r["page_code"], []).append(r["code"]) + return out + + +def line_delta(old: str, new: str) -> tuple[int, int]: + """(добавлено, удалено) строк между двумя текстами; две служебные строки заголовка диффа не считаются.""" + lines = list(difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm=""))[2:] + return (sum(1 for x in lines if x.startswith("+")), sum(1 for x in lines if x.startswith("-"))) + + +def history_at(conn, label: str) -> str | None: + """Время последней загрузки истории проекта или None, если её ещё не было.""" + row = conn.execute("SELECT history_at FROM wiki_history WHERE label = ?", (label,)).fetchone() + return row["history_at"] if row else None diff --git a/src/sdlc_hub/modules/wiki/projects.py b/src/sdlc_hub/modules/wiki/projects.py index 7379643..13e732b 100644 --- a/src/sdlc_hub/modules/wiki/projects.py +++ b/src/sdlc_hub/modules/wiki/projects.py @@ -53,6 +53,8 @@ def _drop_copy(conn, label: str) -> None: conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_version WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_history WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_project WHERE label = ?", (label,)) try: base = paths.project_dir(conn, label) diff --git a/src/sdlc_hub/modules/wiki/sync.py b/src/sdlc_hub/modules/wiki/sync.py index 22b1372..db880a6 100644 --- a/src/sdlc_hub/modules/wiki/sync.py +++ b/src/sdlc_hub/modules/wiki/sync.py @@ -14,7 +14,7 @@ from pathlib import Path from ...core import db from ...core.paths import secure_dir -from . import convert, paths, search +from . import convert, history, paths, search log = logging.getLogger(__name__) @@ -42,6 +42,9 @@ class Prepared: old_dir: Path | None = None # прежняя копия после подмены: удаляется в after_result committed: bool = False root_title: str | None = None + with_history: bool = False # агент прислал versions: историю обновить + versions: list = field(default_factory=list) # новые строки wiki_version (с added/removed) + recount: list = field(default_factory=list) # (added, removed, code) известных версий _PREPARED: dict[int, Prepared] = {} @@ -180,6 +183,36 @@ def _allocate_ids(conn, label, pages) -> tuple[dict, list, list]: return ids, new_ids, attach +def _plan_versions(conn, label: str, codes, staged) -> tuple[list[dict], list[tuple[int, int, str]]]: + """Новые версии из стадии и пересчёт «+/−» (R-6: вне транзакции). Берутся версии страниц из codes с кодом, которого + у проекта ещё нет; по затронутой странице версии идут по (created_at, code), предыдущий текст первой — пустой. + Известная версия, чей предшественник новый, пересчитывается.""" + marks = json.dumps(list(codes)) + known = {r["code"] for r in conn.execute( + "SELECT code FROM wiki_version WHERE label = ? AND page_code IN (SELECT value FROM json_each(?))", (label, marks))} + fresh = [s for s in staged if s["page_code"] in set(codes) and s["code"] not in known] + by_page: dict[str, list[dict]] = {} + for s in fresh: + by_page.setdefault(s["page_code"], []).append(dict(s, new=True)) + new_rows, recount = [], [] + for page_code, items in by_page.items(): + for r in conn.execute("SELECT code, created_at, markdown, added, removed FROM wiki_version " + "WHERE label = ? AND page_code = ?", (label, page_code)): + items.append(dict(r, new=False)) + items.sort(key=lambda x: (x["created_at"], x["code"])) + prev, prev_new = "", False + for it in items: + if it["new"] or prev_new: + added, removed = history.line_delta(prev, it["markdown"]) + if it["new"]: + new_rows.append({**{k: it[k] for k in ("code", "page_code", "created_at", "author_login", "author_name", + "markdown")}, "added": added, "removed": removed}) + elif (added, removed) != (it["added"], it["removed"]): + recount.append((added, removed, it["code"])) + prev, prev_new = it["markdown"], it["new"] + return new_rows, recount + + def _abandon(conn, p: Prepared) -> None: """Подготовка не пригодилась: каталог .new и добавленные ею вложения убираются.""" if p.new_dir is not None: @@ -219,6 +252,16 @@ def prepare(conn, cmd: dict, result: dict | None) -> Prepared: if c not in stage: raise SyncFailed(f"нет страницы {c}") pages = [stage[c] for c in codes] + if "versions" in (result or {}): + m = result["versions"] + if isinstance(m, bool) or not isinstance(m, int) or m < 0: + raise SyncFailed("versions — неотрицательное целое") + staged = [json.loads(r["payload"]) for r in conn.execute( + "SELECT payload FROM wiki_version_stage WHERE command_id = ?", (cmd["id"],))] + if len(staged) < m: + raise SyncFailed(f"нет версий: пришло {len(staged)} из {m}") + p.versions, p.recount = _plan_versions(conn, label, codes, staged) + p.with_history = True cur, _, old = _siblings(conn, label, cmd["id"]) secure_dir(p.new_dir.parent) shutil.rmtree(p.new_dir, ignore_errors=True) @@ -320,6 +363,12 @@ def _swap(cur: Path, new: Path, old: Path) -> bool: return had +def _clear_stage(conn, cmd_id: int) -> None: + """Стадия страниц и версий команды.""" + conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd_id,)) + conn.execute("DELETE FROM wiki_version_stage WHERE command_id = ?", (cmd_id,)) + + def _fail(conn, cmd, error) -> None: """Команда уже записана как done, но копия не применилась: failed + sync_error.""" conn.execute("UPDATE command SET status = 'failed', error = ? WHERE id = ?", (error[:1500], cmd["id"])) @@ -345,7 +394,7 @@ def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_di if not p.discarded: out = dict(result or {}, discarded="корень сменился") conn.execute("UPDATE command SET result = ? WHERE id = ?", (json.dumps(out, ensure_ascii=False), cmd["id"])) - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (now, p.label)) return try: @@ -372,7 +421,18 @@ def _commit(conn, p: Prepared, now: str) -> None: conn.execute("DELETE FROM wiki_attachment WHERE id = ?", (r["id"],)) conn.execute("UPDATE wiki_project SET synced_at = ?, pages_count = ?, root_title = ?, sync_error = NULL, failed_at = NULL " "WHERE label = ?", (now, len(p.rows), p.root_title, p.label)) - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (p.cmd_id,)) + if p.with_history: + conn.execute("DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value FROM json_each(?))", + (p.label, json.dumps([r["code"] for r in p.rows]))) + conn.executemany( + "INSERT OR IGNORE INTO wiki_version(label, code, page_code, created_at, author_login, author_name, markdown, " + "added, removed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [(p.label, v["code"], v["page_code"], v["created_at"], v["author_login"], v["author_name"], v["markdown"], + v["added"], v["removed"]) for v in p.versions]) + conn.executemany("UPDATE wiki_version SET added = ?, removed = ? WHERE label = ? AND code = ?", + [(a, r, p.label, code) for a, r, code in p.recount]) + conn.execute("INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, ?)", (p.label, now)) + _clear_stage(conn, p.cmd_id) cur, new, old = _siblings(conn, p.label, p.cmd_id) if _swap(cur, new, old): # последним: сбой откатывает строки (savepoint) p.old_dir = old @@ -382,7 +442,7 @@ def on_failed_or_reported_failed(conn, cmd: dict, error: str | None) -> None: """Отчёт failed или предел выдач: sync_error и failed_at проекта (если корень команды ещё текущий), стадия очищена.""" if cmd["type"] != "wiki_sync": return - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) conn.execute("UPDATE wiki_project SET sync_error = ?, failed_at = ? WHERE label = ? AND root_code = ?", ((error or "")[:1500], db.now(), cmd["params"]["label"], cmd["params"]["root"])) @@ -396,7 +456,7 @@ def on_failed(conn, cmd, now) -> None: def on_claim(conn, cmd, now, redelivery) -> None: """Повторная выдача: пачки прошлой выдачи не нужны, агент пришлёт заново.""" if cmd["type"] == "wiki_sync" and redelivery: - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) def after_result(conn, cmd, status) -> None: @@ -443,8 +503,9 @@ def finish_pending_swaps(root: Path) -> None: def housekeeping(conn) -> None: """Стадия команд, которые уже не в работе (отмена, терминальный статус), очищается; отменённая синхронизация сдвигает failed_at проекта, чтобы автосинхронизация не ставила пакеты каждый тик.""" - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id NOT IN " - "(SELECT id FROM command WHERE status IN ('queued', 'claimed'))") + live = "(SELECT id FROM command WHERE status IN ('queued', 'claimed'))" + conn.execute(f"DELETE FROM wiki_sync_stage WHERE command_id NOT IN {live}") + conn.execute(f"DELETE FROM wiki_version_stage WHERE command_id NOT IN {live}") for r in conn.execute("SELECT label, synced_at, failed_at FROM wiki_project").fetchall(): last = max((t for t in (r["synced_at"], r["failed_at"]) if t), default="") c = conn.execute("SELECT MAX(finished_at) AS t FROM command WHERE type = 'wiki_sync' AND status = 'cancelled' " diff --git a/tests/test_wiki_history.py b/tests/test_wiki_history.py index b8fc389..670b7f8 100644 --- a/tests/test_wiki_history.py +++ b/tests/test_wiki_history.py @@ -129,3 +129,210 @@ def test_versions_wrong_agent(api): # токеном агента jira (api.ji ]) def test_history_utc(raw, expected): assert history.utc(raw) == expected + + +# --- Task 2: known_versions и запись версий в синхронизации --------------------------------------------------- + +import sqlite3 # noqa: E402 + +from sdlc_hub.modules.wiki import convert, stage, sync # noqa: E402 + + +def run_history(api, pages, versions, codes=None, reported=None): + """Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len).""" + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages}) + for i in range(0, len(versions), 50): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]}) + result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3, + "versions": len(versions) if reported is None else reported} + return cmd, api.report(cmd, "done", result) + + +def versions_of(api): + return api.rows("SELECT code, page_code, added, removed, created_at, markdown FROM wiki_version " + "ORDER BY page_code, created_at, code") + + +def first_load(api): + vs = ([version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "

а

" * (i + 1)) for i in range(3)] + + [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)]) + return run_history(api, TREE, vs, CODES) + + +def stage_count(api): + return api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] + + +def test_first_history_load(api): + cmd, done = first_load(api) + assert cmd["params"]["known_versions"] == {} and done["status"] == "done" + rows = versions_of(api) + assert len(rows) == 5 + assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)] + assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"] + assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"] + + +def test_known_versions_on_delivery_and_recovery(api): + first_load(api) + api.enqueue() + cmd = api.claim() + expected = {"DOC-1": ["DOCH-10", "DOCH-11", "DOCH-12"], "DOC-3": ["DOCH-30", "DOCH-31"]} + assert cmd["params"]["known_versions"] == expected + assert api.wiki.get(f"/agent/commands/{cmd['id']}")["params"]["known_versions"] == expected + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-1", "DOCH-13", "2026-10-25T10:00:00Z", "

б

")]}) + api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) + rows = {r["code"]: r for r in versions_of(api)} + assert (rows["DOCH-13"]["added"], rows["DOCH-13"]["removed"]) == history.line_delta(rows["DOCH-12"]["markdown"], rows["DOCH-13"]["markdown"]) + + +def test_agent_without_history(api): + first_load(api) + before = api.rows("SELECT history_at FROM wiki_history") + old_sync = api.project()["wiki_synced_at"] + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + assert api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1})["status"] == "done" + assert api.project()["wiki_synced_at"] >= old_sync + assert len(versions_of(api)) == 5 and api.rows("SELECT history_at FROM wiki_history") == before + + +def test_page_left_tree(api): + first_load(api) + api.enqueue() + cmd, done = run_history(api, [TREE[0], TREE[2]], [], ["DOC-1"]) + assert done["status"] == "done" + assert {r["page_code"] for r in versions_of(api)} == {"DOC-1"} + + +def test_foreign_page_version_dropped(api): + _, done = run_history(api, TREE, [version("DOC-99", "DOCH-9", "2026-10-25T10:00:00Z")], CODES) + assert done["status"] == "done" and versions_of(api) == [] + + +def test_lost_versions_batch(api): + first_load(api) + api.enqueue() + old_sync = api.project()["wiki_synced_at"] + _, done = run_history(api, TREE, [version("DOC-1", f"DOCH-N{i}", "2026-10-26T10:00:00Z") for i in range(3)], CODES, reported=5) + assert done["status"] == "failed" and done["error"] == "нет версий: пришло 3 из 5" + assert api.project()["wiki_synced_at"] == old_sync and len(versions_of(api)) == 5 + assert stage_count(api) == 0 and api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0 + + +def test_resend_counts_unique(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + batch = [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z"), version("DOC-1", "DOCH-2", "2026-10-26T10:00:00Z")] + for _ in range(2): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch}) + done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 2}) + assert done["status"] == "done" and len(versions_of(api)) == 2 + + +@pytest.mark.parametrize("bad", ["5", True, -1, 1.5]) +def test_versions_field_bad(api, bad): + _, done = run_history(api, TREE, [], CODES, reported=bad) + assert done["status"] == "failed" and done["error"] == "versions — неотрицательное целое" + + +def test_late_version_recounts_successor(api): + run_history(api, TREE, [version("DOC-1", "V1", "2026-10-25T10:00:00Z", "

а

"), + version("DOC-1", "V3", "2026-10-25T12:00:00Z", "

а

в

")], CODES) + api.enqueue() + run_history(api, TREE, [version("DOC-1", "V2", "2026-10-25T11:00:00Z", "

а

б

")], CODES) + r = {x["code"]: x for x in versions_of(api)} + assert (r["V2"]["added"], r["V2"]["removed"]) == history.line_delta(r["V1"]["markdown"], r["V2"]["markdown"]) + assert (r["V3"]["added"], r["V3"]["removed"]) == history.line_delta(r["V2"]["markdown"], r["V3"]["markdown"]) + assert (r["V3"]["added"], r["V3"]["removed"]) != history.line_delta(r["V1"]["markdown"], r["V3"]["markdown"]) + + +def test_known_version_resent_ignored(api): + first_load(api) + before = versions_of(api) + api.enqueue() + run_history(api, TREE, [version("DOC-1", "DOCH-11", "2026-10-21T10:00:00Z", "

совсем другое

")], CODES) + assert versions_of(api) == before + + +def test_reset_root_drops_history(api): + first_load(api) + api.client.patch("/projects/AWPCA", {"wiki_root": None}) + assert versions_of(api) == [] and api.rows("SELECT * FROM wiki_history") == [] + + +def test_redelivery_clears_version_stage(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) + assert stage_count(api) == 1 + again = api.claim() + assert again["id"] == cmd["id"] and stage_count(api) == 0 + + +def test_discarded_report_keeps_history(api): + first_load(api) + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + c = api.hub.conn() + try: + c.execute("UPDATE wiki_project SET root_code = 'DOC-7'") + c.commit() + finally: + c.close() + done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) + assert done["result"]["discarded"] and len(versions_of(api)) == 5 and stage_count(api) == 0 + + +def test_failed_report_clears_version_stage(api): + first_load(api) + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) + api.report(cmd, "failed", None, "Eva: HTTP 503") + assert stage_count(api) == 0 and len(versions_of(api)) == 5 + + +def test_housekeeping_clears_version_stage(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) + c = api.hub.conn() + try: + c.execute("UPDATE command SET status = 'cancelled' WHERE id = ?", (cmd["id"],)) + sync.housekeeping(c) + c.commit() + finally: + c.close() + assert stage_count(api) == 0 + + +def test_history_heavy_work_off_write_lock(api, monkeypatch): + seen = [] + + def probe(tag): + other = sqlite3.connect(api.db_path, timeout=0) + try: + other.execute("BEGIN IMMEDIATE") + other.execute("ROLLBACK") + seen.append((tag, "free")) + except sqlite3.OperationalError: + seen.append((tag, "locked")) + finally: + other.close() + + real_md, real_delta = convert.to_markdown, sync.history.line_delta + monkeypatch.setattr(stage.convert, "to_markdown", lambda *a, **k: (probe("markdown"), real_md(*a, **k))[1]) + monkeypatch.setattr(sync.history, "line_delta", lambda *a: (probe("delta"), real_delta(*a))[1]) + first_load(api) + assert ("delta", "free") in seen and ("markdown", "free") in seen and not [s for s in seen if s[1] == "locked"] + + +def test_line_delta(): + assert history.line_delta("", "a\nb\n") == (2, 0) + assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1) + assert history.line_delta("a\n", "a\n") == (0, 0)