Вики: known_versions при выдаче и запись версий в синхронизации (#140)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 12:06:39 +04:00
parent 7b18ae53ad
commit a11d66f94e
6 changed files with 317 additions and 16 deletions

View File

@ -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:<id>` и `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-<id>/<имя>`.
- Агент за 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`.

View File

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

View File

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

View File

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

View File

@ -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' "

View File

@ -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", "<p>а</p>" * (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", "<p>б</p>")]})
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", "<p>а</p>"),
version("DOC-1", "V3", "2026-10-25T12:00:00Z", "<p>а</p><p>в</p>")], CODES)
api.enqueue()
run_history(api, TREE, [version("DOC-1", "V2", "2026-10-25T11:00:00Z", "<p>а</p><p>б</p>")], 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", "<p>совсем другое</p>")], 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)