wiki: синхронизация — пачки страниц, копия, поиск (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4faf7884ad
commit
bc69d5087a
@ -30,6 +30,8 @@ class CommandHooks:
|
||||
on_result: Callable[[Connection, dict, str, dict | None, int, int, str, Path | None], None] # в транзакции
|
||||
after_result: Callable[[Connection, dict, str], None] # вне транзакции
|
||||
on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
|
||||
# (conn, cmd, status, result, files_dir): до транзакции отчёта — тяжёлая подготовка (файлы, перевод); None — нет
|
||||
before_result: Callable[[Connection, dict, str, dict | None, Path | None], None] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -295,6 +295,11 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
if status not in ("done", "failed"):
|
||||
raise ValueError("status — done или failed")
|
||||
error = None if error is None else str(error)[:ERROR_LIMIT]
|
||||
early = get_command(conn, command_id)
|
||||
if early is not None and early["status"] == "claimed":
|
||||
eh = _hooks(early["type"])
|
||||
if eh and eh.before_result: # вне транзакции отчёта: модуль готовит тяжёлое, не держа блокировку базы
|
||||
eh.before_result(conn, early, status, result, files_dir)
|
||||
with db.transaction(conn):
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Модуль wiki: Markdown-копия вики Eva по проектам, поиск, вложения по запросу."""
|
||||
from ...core.modules import Module
|
||||
from . import commands, projects, schema, settings, tick
|
||||
from . import api, commands, projects, schema, settings, tick
|
||||
|
||||
MODULE = Module(
|
||||
name="wiki",
|
||||
@ -10,7 +10,8 @@ MODULE = Module(
|
||||
agent_min_version_key="wiki_min_agent_version",
|
||||
validate_setting=settings.validate_setting,
|
||||
commands=commands.HOOKS,
|
||||
tick=(tick.auto_sync_tick,),
|
||||
routes=api.ROUTES,
|
||||
tick=(tick.housekeeping_tick, tick.auto_sync_tick),
|
||||
project_fields=projects.FIELDS,
|
||||
project_view=projects.view,
|
||||
on_project_created=projects.on_created,
|
||||
|
||||
11
src/jira_hub/modules/wiki/api.py
Normal file
11
src/jira_hub/modules/wiki/api.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""HTTP модуля wiki: маршруты агента (приём пачек страниц)."""
|
||||
from . import stage
|
||||
|
||||
|
||||
def h_agent_pages(hub, conn, m, q, body):
|
||||
return 200, stage.accept(conn, body.get("command_id"), body.get("pages"))
|
||||
|
||||
|
||||
ROUTES = (
|
||||
("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"),
|
||||
)
|
||||
@ -4,6 +4,7 @@ import re
|
||||
from ...core import queue
|
||||
from ...core.errors import CommandError
|
||||
from ...core.modules import CommandHooks
|
||||
from . import sync
|
||||
|
||||
TYPES = {"wiki_sync", "wiki_fetch"}
|
||||
SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []},
|
||||
@ -68,14 +69,12 @@ def active_sync(conn, label: str) -> dict | None:
|
||||
return queue.batch_view(conn, row["id"], with_commands=False) if row else None
|
||||
|
||||
|
||||
_noop = lambda *a, **k: None # noqa: E731
|
||||
|
||||
# Хуки результата пустые: их заполняют приём пачек и отчёт синхронизации
|
||||
# wiki_fetch: хуки результата заполнит Task 8; wiki_sync — sync.py
|
||||
HOOKS = CommandHooks(
|
||||
types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch,
|
||||
tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["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=_noop, on_failed=_noop,
|
||||
check_result=lambda conn, cmd, status, result, files_dir: (status, None), on_result=_noop, after_result=_noop,
|
||||
on_late_result=lambda *a: False)
|
||||
check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=sync.on_failed,
|
||||
check_result=sync.check_result, on_result=sync.on_result, after_result=sync.after_result,
|
||||
on_late_result=lambda *a: False, before_result=sync.before_result)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта."""
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
@ -6,6 +7,8 @@ from ...core import db, queue
|
||||
from ...core.modules import ProjectField
|
||||
from . import commands, paths
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
@ -63,14 +66,21 @@ def _has_table(conn, name: str) -> bool:
|
||||
def _drop_copy(conn, label: str) -> None:
|
||||
"""Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии."""
|
||||
if _has_table(conn, "wiki_page_fts"):
|
||||
conn.execute("DELETE FROM wiki_page_fts WHERE rowid IN (SELECT rowid FROM wiki_page WHERE label = ?)", (label,))
|
||||
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_project WHERE label = ?", (label,))
|
||||
try:
|
||||
shutil.rmtree(paths.project_dir(conn, label), ignore_errors=True)
|
||||
base = paths.project_dir(conn, label)
|
||||
except RuntimeError: # база без файла (тесты в памяти): копии на диске нет
|
||||
pass
|
||||
return
|
||||
for d in (base, base.with_name(f".{base.name}.new"), base.with_name(f".{base.name}.old")):
|
||||
try:
|
||||
shutil.rmtree(d)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("вики: каталог %s не удалён: %s", d, e)
|
||||
|
||||
|
||||
def on_updated(conn, project, fields, author) -> list[dict]:
|
||||
|
||||
88
src/jira_hub/modules/wiki/search.py
Normal file
88
src/jira_hub/modules/wiki/search.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Поиск по копии вики: FTS5 (wiki_page_fts) и запасной LIKE по файлам index.md, если FTS5 в SQLite нет."""
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from . import paths
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_WORD = re.compile(r"\w+")
|
||||
_warned = False
|
||||
|
||||
|
||||
def _has_table(conn) -> bool:
|
||||
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() is not None
|
||||
|
||||
|
||||
def ensure(conn) -> bool:
|
||||
"""Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс)."""
|
||||
global _warned
|
||||
if _has_table(conn):
|
||||
return True
|
||||
try:
|
||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, "
|
||||
"title, body, tokenize='unicode61 remove_diacritics 2')")
|
||||
except sqlite3.OperationalError as e:
|
||||
if not _warned:
|
||||
_warned = True
|
||||
log.warning("FTS5 недоступен, поиск по вики идёт по файлам: %s", e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def replace(conn, label: str, pages: list[tuple[str, str, str]]) -> None:
|
||||
"""Индекс проекта заново: pages — [(код, название, Markdown)]. Без FTS5 — ничего."""
|
||||
if not ensure(conn):
|
||||
return
|
||||
conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,))
|
||||
conn.executemany("INSERT INTO wiki_page_fts(label, code, title, body) VALUES (?, ?, ?, ?)",
|
||||
[(label, code, title, body) for code, title, body in pages])
|
||||
|
||||
|
||||
def _match(q: str) -> str | None:
|
||||
"""Слова запроса → `"слово"* AND …`: префиксный поиск, кавычки экранируются. Без слов — None."""
|
||||
words = [w.replace('"', '""') for w in _WORD.findall(q or "")]
|
||||
return " AND ".join(f'"{w}"*' for w in words) if words else None
|
||||
|
||||
|
||||
def query(conn, label: str, q: str, limit: int) -> list[dict]:
|
||||
"""[{code, title, path, snippet}] по убыванию релевантности."""
|
||||
match = _match(q)
|
||||
if match is None:
|
||||
return []
|
||||
pages = {r["code"]: r for r in conn.execute("SELECT code, title, path FROM wiki_page WHERE label = ?", (label,))}
|
||||
if _has_table(conn):
|
||||
try:
|
||||
rows = conn.execute("SELECT code, title, snippet(wiki_page_fts, 3, '', '', '…', 16) AS snippet "
|
||||
"FROM wiki_page_fts WHERE wiki_page_fts MATCH ? AND label = ? ORDER BY rank LIMIT ?",
|
||||
(match, label, limit)).fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
log.warning("поиск по вики: запрос «%s» отвергнут: %s", q, e)
|
||||
return []
|
||||
return [{"code": r["code"], "title": r["title"], "path": pages[r["code"]]["path"] if r["code"] in pages else None,
|
||||
"snippet": r["snippet"]} for r in rows]
|
||||
return _like(conn, label, _WORD.findall(q), pages, limit)
|
||||
|
||||
|
||||
def _like(conn, label, words, pages, limit) -> list[dict]:
|
||||
"""Без FTS5: страницы проекта, где есть все слова (без учёта регистра), по числу вхождений."""
|
||||
try:
|
||||
base = paths.project_dir(conn, label)
|
||||
except RuntimeError:
|
||||
return []
|
||||
found = []
|
||||
needles = [w.casefold() for w in words]
|
||||
for code, r in pages.items():
|
||||
try:
|
||||
text = (base / r["path"] / "index.md").read_text(encoding="utf-8")
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
low = text.casefold()
|
||||
if all(n in low for n in needles):
|
||||
at = low.find(needles[0])
|
||||
start = max(0, at - 60)
|
||||
found.append((-sum(low.count(n) for n in needles), code, r["title"], r["path"],
|
||||
("…" if start else "") + text[start:at + 100].replace("\n", " ") + "…"))
|
||||
found.sort()
|
||||
return [{"code": c, "title": t, "path": p, "snippet": s} for _, c, t, p, s in found[:limit]]
|
||||
81
src/jira_hub/modules/wiki/stage.py
Normal file
81
src/jira_hub/modules/wiki/stage.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Приём пачек страниц от агента wiki: проверка формы, перевод HTML → Markdown (вне транзакции), стадия команды."""
|
||||
import json
|
||||
|
||||
from ...core import db, queue
|
||||
from ...core.http import ApiError
|
||||
from . import convert
|
||||
|
||||
MAX_PAGES = 50
|
||||
|
||||
|
||||
def _bad(code, field):
|
||||
return ApiError(422, "bad_pages", f"{code}: {field}")
|
||||
|
||||
|
||||
def _text(page, key, code, required=True, nullable=False):
|
||||
v = page.get(key)
|
||||
if v is None and (nullable or not required):
|
||||
return None
|
||||
if not isinstance(v, str) or (required and not v.strip()):
|
||||
raise _bad(code, key)
|
||||
return v
|
||||
|
||||
|
||||
def _check(i, page) -> dict:
|
||||
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
|
||||
if not isinstance(page, dict):
|
||||
raise ApiError(422, "bad_pages", f"страница {i}: не объект")
|
||||
code = page.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
raise ApiError(422, "bad_pages", f"страница {i}: code")
|
||||
title = _text(page, "title", code)
|
||||
html = page.get("html")
|
||||
if not isinstance(html, str):
|
||||
raise _bad(code, "html")
|
||||
orderno = page.get("orderno")
|
||||
if orderno is not None and (not isinstance(orderno, int) or isinstance(orderno, bool)):
|
||||
raise _bad(code, "orderno")
|
||||
eva_id = page.get("eva_id")
|
||||
if eva_id is not None and not isinstance(eva_id, str):
|
||||
raise _bad(code, "eva_id")
|
||||
attachments, links = page.get("attachments") or [], page.get("links") or []
|
||||
if not isinstance(attachments, list) or not all(
|
||||
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
|
||||
and isinstance(a.get("name") or "", str) for a in attachments):
|
||||
raise _bad(code, "attachments")
|
||||
if not isinstance(links, list) or not all(
|
||||
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str) for l in links):
|
||||
raise _bad(code, "links")
|
||||
return {"code": code, "eva_id": eva_id, "title": title, "parent_code": _text(page, "parent_code", code, False, True),
|
||||
"orderno": orderno, "url": _text(page, "url", code, False, True), "html": html,
|
||||
"attachments": [{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments],
|
||||
"links": [{"ref": l["ref"], "code": l["code"]} for l in links]}
|
||||
|
||||
|
||||
def accept(conn, command_id, pages) -> dict:
|
||||
"""Пачка страниц команды wiki_sync → стадия. Перевод HTML идёт до записи, строки пишутся короткой транзакцией."""
|
||||
if isinstance(command_id, bool) or not isinstance(command_id, int):
|
||||
raise ApiError(422, "bad_request", "command_id — целое")
|
||||
if not isinstance(pages, list) or not pages or len(pages) > MAX_PAGES:
|
||||
raise ApiError(422, "bad_pages", f"pages — список от 1 до {MAX_PAGES} страниц")
|
||||
checked = [_check(i, p) for i, p in enumerate(pages, 1)]
|
||||
cmd = queue.get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
raise ApiError(404, "not_found", f"command {command_id}")
|
||||
if cmd["type"] != "wiki_sync" or cmd["status"] != "claimed":
|
||||
raise ApiError(409, "not_claimed", f"команда {command_id} не выдана агенту как wiki_sync")
|
||||
rows = []
|
||||
for p in checked:
|
||||
try:
|
||||
md, files = convert.to_markdown(p["html"], p["attachments"], p["links"])
|
||||
except Exception as e: # noqa: BLE001 недоверенный HTML: сбой разбора — ошибка страницы, не сервера
|
||||
raise _bad(p["code"], f"html ({type(e).__name__})") from None
|
||||
payload = {k: p[k] for k in ("code", "eva_id", "title", "parent_code", "orderno", "url")}
|
||||
payload |= {"markdown": md, "attachments": files}
|
||||
rows.append((command_id, p["code"], json.dumps(payload, ensure_ascii=False)))
|
||||
with db.transaction(conn):
|
||||
cur = conn.execute("SELECT status FROM command WHERE id = ?", (command_id,)).fetchone()
|
||||
if cur is None or cur["status"] != "claimed": # состояние могло смениться, пока шёл перевод
|
||||
raise ApiError(409, "not_claimed", f"команда {command_id} уже не в работе")
|
||||
conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", rows)
|
||||
return {"accepted": len(rows)}
|
||||
389
src/jira_hub/modules/wiki/sync.py
Normal file
389
src/jira_hub/modules/wiki/sync.py
Normal file
@ -0,0 +1,389 @@
|
||||
"""Синхронизация вики: сборка Markdown-копии из стадии, подмена каталога, строки wiki_page/вложений/FTS.
|
||||
|
||||
Порядок отчёта `done` (design 10): `prepare` вызывается до транзакции отчёта (CommandHooks.before_result) — готовит
|
||||
каталог `wiki/.<метка>.new/` и выделяет id вложений; `commit` (в on_result) пишет строки и последним шагом подменяет
|
||||
каталог. Перевод HTML → Markdown уже сделан при приёме пачек (stage.accept)."""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...core import db
|
||||
from ...core.paths import secure_dir
|
||||
from . import convert, paths, search
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BAD_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
NAME_CHARS = 80 # длина названия, символов (как safe_name в eva_wiki_export.py)
|
||||
NAME_BYTES = 200 # и байт UTF-8: у файловой системы предел имени 255 байт, «NN_» и «_k» помещаются
|
||||
|
||||
|
||||
class SyncFailed(Exception):
|
||||
"""Синхронизация не удалась: текст идёт в error команды и sync_error проекта."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prepared:
|
||||
cmd_id: int
|
||||
label: str
|
||||
root: str
|
||||
discarded: str | None = None
|
||||
error: str | None = None
|
||||
rows: list = field(default_factory=list)
|
||||
attachments: list = field(default_factory=list) # (page_code, url, name)
|
||||
kept_ids: set = field(default_factory=set)
|
||||
new_ids: list = field(default_factory=list) # id вложений, добавленных подготовкой
|
||||
new_dir: Path | None = None
|
||||
root_title: str | None = None
|
||||
|
||||
|
||||
_PREPARED: dict[int, Prepared] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
# --- раскладка копии ----------------------------------------------------------
|
||||
|
||||
def safe_name(title: str) -> str:
|
||||
"""Название каталога: запрещённые символы → «_», до 80 символов и 200 байт, «.» и пробелы по краям убраны."""
|
||||
name = _BAD_CHARS.sub("_", title or "")[:NAME_CHARS]
|
||||
name = name.encode("utf-8")[:NAME_BYTES].decode("utf-8", "ignore").strip(" .")
|
||||
return name or "untitled"
|
||||
|
||||
|
||||
def _sorted(codes, by_code):
|
||||
return sorted(codes, key=lambda c: (by_code[c]["orderno"] is None, by_code[c]["orderno"] or 0, c))
|
||||
|
||||
|
||||
def _tree(order, by_code) -> dict[str, list[str]]:
|
||||
"""Потомки по parent_code; страница без родителя в наборе (или в цикле) становится дочерней корню."""
|
||||
root = order[0]
|
||||
children = {c: [] for c in order}
|
||||
parent = {}
|
||||
for c in order[1:]:
|
||||
p = by_code[c].get("parent_code")
|
||||
parent[c] = p if p in children and p != c else root
|
||||
children[parent[c]].append(c)
|
||||
|
||||
def reach():
|
||||
seen, stack = set(), [root]
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
stack.extend(children[c])
|
||||
return seen
|
||||
|
||||
seen = reach()
|
||||
for c in order[1:]:
|
||||
if c not in seen: # цикл родителей: оторванное звено цепляем к корню
|
||||
children[parent[c]].remove(c)
|
||||
parent[c] = root
|
||||
children[root].append(c)
|
||||
seen = reach()
|
||||
return children
|
||||
|
||||
|
||||
def _inside(target: Path, path: Path) -> None:
|
||||
if not path.resolve().is_relative_to(target.resolve()):
|
||||
raise ValueError(f"путь {path} вне каталога копии")
|
||||
|
||||
|
||||
def build_copy(stage_pages: list[dict], codes: list[str], target: Path, ids: dict[tuple[str, str], int]) -> list[dict]:
|
||||
"""Собрать копию дерева в `target`: index.md страниц и _tree.json. Корень — codes[0]. Возвращает строки wiki_page
|
||||
(с полем markdown — текст с подставленными id вложений). ValueError — страница или путь негодны."""
|
||||
by_code = {p["code"]: p for p in stage_pages}
|
||||
order = list(dict.fromkeys(codes))
|
||||
if not order:
|
||||
raise ValueError("пустой список страниц")
|
||||
for c in order:
|
||||
if c not in by_code:
|
||||
raise ValueError(f"нет страницы {c}")
|
||||
children = _tree(order, by_code)
|
||||
secure_dir(target)
|
||||
synced_at = db.now()
|
||||
rows, tree = [], []
|
||||
stack = [(order[0], Path(), 0, 0)]
|
||||
used: dict[str, set[str]] = {} # родитель → занятые имена (без регистра)
|
||||
while stack:
|
||||
code, parent_rel, depth, pos = stack.pop()
|
||||
page = by_code[code]
|
||||
taken = used.setdefault(str(parent_rel), set())
|
||||
name, k = safe_name(page["title"]), 1
|
||||
base = name
|
||||
while name.lower() in taken:
|
||||
k += 1
|
||||
name = f"{base}_{k}"
|
||||
taken.add(name.lower())
|
||||
rel = parent_rel / (name if depth == 0 else f"{pos:02d}_{name}")
|
||||
full = target / rel
|
||||
_inside(target, full)
|
||||
try:
|
||||
text = convert.fill_ids(page["markdown"], [ids[(code, a["url"])] for a in page["attachments"]])
|
||||
except (ValueError, KeyError) as e:
|
||||
raise ValueError(f"{code}: {e}") from None
|
||||
secure_dir(full)
|
||||
title_line = " ".join(str(page["title"]).split())
|
||||
head = ["---", f"code: {json.dumps(code, ensure_ascii=False)}", f"title: {json.dumps(page['title'], ensure_ascii=False)}",
|
||||
f"url: {json.dumps(page.get('url'), ensure_ascii=False)}", f"synced_at: {json.dumps(synced_at)}", "---", "",
|
||||
f"# {title_line}", ""]
|
||||
(full / "index.md").write_text("\n".join(head) + "\n" + text, encoding="utf-8")
|
||||
rows.append({"code": code, "eva_id": page.get("eva_id"), "title": page["title"], "parent_code": page.get("parent_code"),
|
||||
"orderno": page.get("orderno"), "depth": depth, "path": rel.as_posix(), "url": page.get("url"),
|
||||
"size": len(text), "synced_at": synced_at, "markdown": text})
|
||||
tree.append({"code": code, "title": page["title"], "depth": depth, "path": rel.as_posix()})
|
||||
kids = _sorted(children[code], by_code)
|
||||
for i, kid in reversed(list(enumerate(kids, 1))): # стек: первый ребёнок — следующим
|
||||
stack.append((kid, rel, depth + 1, i))
|
||||
(target / "_tree.json").write_text(json.dumps(tree, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
return rows
|
||||
|
||||
|
||||
# --- подготовка (до транзакции отчёта) ---------------------------------------
|
||||
|
||||
def _siblings(conn, label: str) -> tuple[Path, Path, Path]:
|
||||
cur = paths.project_dir(conn, label)
|
||||
return cur, cur.with_name(f".{cur.name}.new"), cur.with_name(f".{cur.name}.old")
|
||||
|
||||
|
||||
def _allocate_ids(conn, label, pages) -> tuple[dict, list, list]:
|
||||
"""id вложений страниц: существующие по (страница, url) сохраняются, новые вставляются короткой транзакцией."""
|
||||
ids, new_ids, attach = {}, [], []
|
||||
existing = {(r["page_code"], r["url"]): r["id"] for r in conn.execute(
|
||||
"SELECT id, page_code, url FROM wiki_attachment WHERE label = ?", (label,))}
|
||||
with db.transaction(conn):
|
||||
for page in pages:
|
||||
for a in page["attachments"]:
|
||||
key = (page["code"], a["url"])
|
||||
if key not in existing:
|
||||
cur = conn.execute("INSERT INTO wiki_attachment(label, page_code, url, name) VALUES (?, ?, ?, ?)",
|
||||
(label, key[0], key[1], a["name"]))
|
||||
existing[key] = cur.lastrowid
|
||||
new_ids.append(cur.lastrowid)
|
||||
ids[key] = existing[key]
|
||||
attach.append((page["code"], a["url"], a["name"]))
|
||||
return ids, new_ids, attach
|
||||
|
||||
|
||||
def _abandon(conn, p: Prepared) -> None:
|
||||
"""Подготовка не пригодилась: каталог .new и добавленные ею вложения убираются."""
|
||||
if p.new_dir is not None:
|
||||
shutil.rmtree(p.new_dir, ignore_errors=True)
|
||||
if p.new_ids:
|
||||
marks = ",".join("?" * len(p.new_ids))
|
||||
conn.execute(f"DELETE FROM wiki_attachment WHERE id IN ({marks}) AND state = 'none'", p.new_ids)
|
||||
p.new_ids = []
|
||||
|
||||
|
||||
def prepare(conn, cmd: dict, result: dict | None) -> Prepared:
|
||||
"""До транзакции: проверить корень и список кодов, выделить id вложений, собрать `.new`. Ошибка — в p.error."""
|
||||
label, root = cmd["params"]["label"], cmd["params"]["root"]
|
||||
p = Prepared(cmd["id"], label, root)
|
||||
row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (label,)).fetchone()
|
||||
if row is None or row["root_code"] != root:
|
||||
p.discarded = "корень сменился"
|
||||
return p
|
||||
try:
|
||||
codes = (result or {}).get("codes")
|
||||
if not isinstance(codes, list) or not codes or not all(isinstance(c, str) for c in codes):
|
||||
raise SyncFailed("в отчёте нет списка codes")
|
||||
codes = list(dict.fromkeys(codes))
|
||||
if codes[0] != root:
|
||||
raise SyncFailed(f"первой в codes должна быть страница корня {root}")
|
||||
stage = {r["code"]: json.loads(r["payload"]) for r in conn.execute(
|
||||
"SELECT code, payload FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],))}
|
||||
for c in codes:
|
||||
if c not in stage:
|
||||
raise SyncFailed(f"нет страницы {c}")
|
||||
pages = [stage[c] for c in codes]
|
||||
_, p.new_dir, _ = _siblings(conn, label)
|
||||
secure_dir(p.new_dir.parent)
|
||||
shutil.rmtree(p.new_dir, ignore_errors=True)
|
||||
ids, p.new_ids, p.attachments = _allocate_ids(conn, label, pages)
|
||||
p.kept_ids = set(ids.values())
|
||||
p.rows = build_copy(pages, codes, p.new_dir, ids)
|
||||
title = (result or {}).get("root_title")
|
||||
p.root_title = title if isinstance(title, str) and title.strip() else p.rows[0]["title"]
|
||||
except SyncFailed as e:
|
||||
p.error = str(e)
|
||||
except ValueError as e: # fill_ids, путь вне копии
|
||||
p.error = str(e)
|
||||
except OSError as e:
|
||||
p.error = f"не удалось собрать копию: {e}"
|
||||
except Exception as e: # noqa: BLE001 сбой не должен оставлять команду без отчёта
|
||||
log.exception("wiki_sync %s: сборка копии", cmd["id"])
|
||||
p.error = f"внутренняя ошибка сборки копии: {type(e).__name__}: {e}"
|
||||
if p.error:
|
||||
_abandon(conn, p)
|
||||
return p
|
||||
|
||||
|
||||
def before_result(conn, cmd, status, result, files_dir) -> None:
|
||||
if cmd["type"] != "wiki_sync" or status != "done":
|
||||
return
|
||||
p = prepare(conn, cmd, result)
|
||||
with _lock:
|
||||
_PREPARED[cmd["id"]] = p
|
||||
|
||||
|
||||
def _prepared(conn, cmd, result) -> Prepared:
|
||||
with _lock:
|
||||
p = _PREPARED.get(cmd["id"])
|
||||
return p if p is not None else prepare(conn, cmd, result) # в обход before_result: в транзакции, но верно
|
||||
|
||||
|
||||
def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]:
|
||||
if cmd["type"] != "wiki_sync" or status != "done":
|
||||
return status, None
|
||||
p = _prepared(conn, cmd, result)
|
||||
with _lock:
|
||||
_PREPARED[cmd["id"]] = p
|
||||
if p.discarded:
|
||||
if isinstance(result, dict):
|
||||
result["discarded"] = p.discarded
|
||||
return "done", None
|
||||
if p.error:
|
||||
return "failed", p.error
|
||||
return "done", None
|
||||
|
||||
|
||||
# --- применение (в транзакции отчёта) ----------------------------------------
|
||||
|
||||
def _swap(cur: Path, new: Path, old: Path) -> None:
|
||||
"""wiki/<метка> → .old, .new → <метка>, .old удаляется. При сбое прежний каталог возвращается на место."""
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
had = cur.exists()
|
||||
try:
|
||||
if had:
|
||||
cur.rename(old)
|
||||
new.rename(cur)
|
||||
except OSError:
|
||||
if had and not cur.exists() and old.exists():
|
||||
old.rename(cur)
|
||||
raise
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
|
||||
|
||||
def _fail(conn, cmd, error) -> None:
|
||||
"""Команда уже записана как done, но копия не применилась: failed + sync_error."""
|
||||
conn.execute("UPDATE command SET status = 'failed', error = ? WHERE id = ?", (error[:1500], cmd["id"]))
|
||||
on_failed_or_reported_failed(conn, cmd, error)
|
||||
|
||||
|
||||
def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) -> None:
|
||||
if cmd["type"] != "wiki_sync":
|
||||
return
|
||||
with _lock:
|
||||
p = _PREPARED.pop(cmd["id"], None)
|
||||
if status != "done":
|
||||
error = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone()["error"]
|
||||
on_failed_or_reported_failed(conn, cmd, error or "синхронизация не удалась")
|
||||
return
|
||||
if p is None or p.error:
|
||||
error = p.error if p else "копия не подготовлена"
|
||||
on_failed_or_reported_failed(conn, cmd, error)
|
||||
return
|
||||
row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (p.label,)).fetchone()
|
||||
if p.discarded or row is None or row["root_code"] != p.root: # корень сменился, пока шла подготовка
|
||||
_abandon(conn, p)
|
||||
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"],))
|
||||
conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (now, p.label))
|
||||
return
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
_commit(conn, p, now)
|
||||
except OSError as e:
|
||||
_abandon(conn, p)
|
||||
_fail(conn, cmd, f"не удалось подменить копию: {e}")
|
||||
|
||||
|
||||
def _commit(conn, p: Prepared, now: str) -> None:
|
||||
search.replace(conn, p.label, [(r["code"], r["title"], r["markdown"]) for r in p.rows])
|
||||
conn.execute("DELETE FROM wiki_page WHERE label = ?", (p.label,))
|
||||
conn.executemany(
|
||||
"INSERT INTO wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[(p.label, r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"],
|
||||
r["size"], r["synced_at"]) for r in p.rows])
|
||||
conn.executemany("UPDATE wiki_attachment SET name = ? WHERE label = ? AND page_code = ? AND url = ?",
|
||||
[(name, p.label, code, url) for code, url, name in p.attachments])
|
||||
for r in conn.execute("SELECT id FROM wiki_attachment WHERE label = ?", (p.label,)).fetchall():
|
||||
if r["id"] not in p.kept_ids:
|
||||
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,))
|
||||
cur, new, old = _siblings(conn, p.label)
|
||||
_swap(cur, new, old) # последним: сбой откатывает строки (savepoint)
|
||||
|
||||
|
||||
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"],))
|
||||
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"]))
|
||||
|
||||
|
||||
def on_failed(conn, cmd, now) -> None:
|
||||
"""Предел выдач: ошибка уже записана в команду."""
|
||||
row = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone()
|
||||
on_failed_or_reported_failed(conn, cmd, row["error"] if row else 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"],))
|
||||
|
||||
|
||||
def after_result(conn, cmd, status) -> None:
|
||||
with _lock:
|
||||
_PREPARED.pop(cmd["id"], None)
|
||||
|
||||
|
||||
# --- уборка ----------------------------------------------------------------
|
||||
|
||||
def finish_pending_swaps(root: Path) -> None:
|
||||
"""При старте: `.<имя>.new` без каталога `<имя>` — переименовать (подмена прервана); `.new` рядом с `<имя>` —
|
||||
остаток неудачной сборки, удаляется; `.<имя>.old` — удаляется. Каталоги, которые собирает идущая подготовка, не трогаются."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return
|
||||
with _lock:
|
||||
busy = {p.new_dir for p in _PREPARED.values() if p.new_dir}
|
||||
for entry in sorted(root.iterdir()):
|
||||
n = entry.name
|
||||
if not n.startswith(".") or entry in busy:
|
||||
continue
|
||||
try:
|
||||
if n.endswith(".new"):
|
||||
target = root / n[1:-4]
|
||||
if target.exists():
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
else:
|
||||
entry.rename(target)
|
||||
elif n.endswith(".old"):
|
||||
shutil.rmtree(entry)
|
||||
except OSError as e:
|
||||
log.warning("вики: не удалось довести подмену %s: %s", entry, e)
|
||||
|
||||
|
||||
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'))")
|
||||
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' "
|
||||
"AND json_extract(params, '$.label') = ?", (r["label"],)).fetchone()["t"]
|
||||
if c and c > last:
|
||||
conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (c, r["label"]))
|
||||
@ -2,7 +2,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from ...core import db, settings
|
||||
from . import commands
|
||||
from . import commands, paths, sync
|
||||
|
||||
|
||||
def auto_sync_tick(hub, conn) -> None:
|
||||
@ -20,3 +20,21 @@ def auto_sync_tick(hub, conn) -> None:
|
||||
|
||||
|
||||
auto_sync_tick.label = "синхронизация вики"
|
||||
|
||||
|
||||
_swept: set[str] = set()
|
||||
|
||||
|
||||
def housekeeping_tick(hub, conn) -> None:
|
||||
"""Первый тик на каталог вики: довести прерванную подмену копии; каждый тик: очистка стадий и отмен."""
|
||||
try:
|
||||
root = paths.root(conn)
|
||||
except RuntimeError: # база без файла: копий на диске нет
|
||||
root = None
|
||||
if root is not None and str(root) not in _swept:
|
||||
_swept.add(str(root))
|
||||
sync.finish_pending_swaps(root)
|
||||
sync.housekeeping(conn)
|
||||
|
||||
|
||||
housekeeping_tick.label = "уборка вики"
|
||||
|
||||
6
tests/fixtures/baseline/routes.expected.json
vendored
6
tests/fixtures/baseline/routes.expected.json
vendored
@ -71,6 +71,12 @@
|
||||
"agent",
|
||||
"h_agent_state"
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"^/agent/wiki/pages$",
|
||||
"agent",
|
||||
"h_agent_pages"
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"^/agents$",
|
||||
|
||||
@ -51,11 +51,12 @@ def test_route_conflict():
|
||||
|
||||
def test_routes_assembled():
|
||||
from jira_hub.core import api
|
||||
from jira_hub.modules import jira
|
||||
from jira_hub.modules import jira, wiki
|
||||
pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731
|
||||
assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(jira.MODULE.routes)
|
||||
assert not pairs(api.CORE_ROUTES) & pairs(jira.MODULE.routes)
|
||||
assert len(api.ROUTES) == len(api.CORE_ROUTES) + len(jira.MODULE.routes)
|
||||
mods = jira.MODULE.routes + wiki.MODULE.routes
|
||||
assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(mods)
|
||||
assert not pairs(api.CORE_ROUTES) & pairs(mods)
|
||||
assert len(api.ROUTES) == len(api.CORE_ROUTES) + len(mods)
|
||||
|
||||
|
||||
def test_module_of(monkeypatch):
|
||||
|
||||
435
tests/test_wiki_sync.py
Normal file
435
tests/test_wiki_sync.py
Normal file
@ -0,0 +1,435 @@
|
||||
"""Синхронизация вики: приём пачек, сборка копии, поиск (wiki-module, spec wiki «Синхронизация вики», «Markdown-копия»).
|
||||
Агента имитирует тест: HTTP на порту 0 токеном агента wiki."""
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, queue, settings
|
||||
from jira_hub.core.client import HubClient, HubError
|
||||
from jira_hub.modules.wiki import commands, convert, projects, search, stage, sync, tick
|
||||
|
||||
|
||||
def page(code, title, parent=None, orderno=None, html=None, attachments=None, links=None):
|
||||
return {"code": code, "eva_id": "id-" + code, "title": title, "parent_code": parent, "orderno": orderno,
|
||||
"url": f"https://eva.example.ru/project/Document/{code}", "html": html if html is not None else f"<p>текст {title}</p>",
|
||||
"attachments": attachments or [], "links": links or []}
|
||||
|
||||
|
||||
TREE = [page("DOC-1", "АРМ КА"), page("DOC-2", "Роли/права", "DOC-1", 2), page("DOC-3", "Статусы", "DOC-1", 1,
|
||||
"<p>статус «На согласовании» ставит аналитик</p>")]
|
||||
CODES = ["DOC-1", "DOC-3", "DOC-2"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(hub_server, tmp_path):
|
||||
hub = hub_server["hub"]
|
||||
conn = hub.conn()
|
||||
token = settings.tokens(conn)["agents"]["wiki"]
|
||||
conn.close()
|
||||
wiki = HubClient(hub_server["base"], token)
|
||||
hub_server["client"].post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
|
||||
|
||||
def db_rows(sql, *args):
|
||||
c = hub.conn()
|
||||
try:
|
||||
return [dict(r) for r in c.execute(sql, args)]
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def enqueue():
|
||||
c = hub.conn()
|
||||
try:
|
||||
with db.transaction(c):
|
||||
return commands.enqueue_sync(c, "AWPCA", "DOC-1", "test")
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def claim():
|
||||
return wiki.post("/agent/claim", {"agent_version": 1})["command"]
|
||||
|
||||
def report(cmd, status="done", result=None, error=None):
|
||||
return wiki.post(f"/agent/commands/{cmd['id']}/result", {"status": status, "result": result, "error": error})
|
||||
|
||||
def run(pages, codes=None, root_title="АРМ КА", status="done", error=None):
|
||||
"""Целый обмен: взять команду, прислать страницы, отчитаться. Возвращает команду после отчёта."""
|
||||
cmd = claim()
|
||||
assert cmd and cmd["type"] == "wiki_sync", cmd
|
||||
if pages:
|
||||
wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages})
|
||||
result = {"root_title": root_title, "codes": codes if codes is not None else [p["code"] for p in pages],
|
||||
"eva_calls": 3} if status == "done" else None
|
||||
return report(cmd, status, result, error)
|
||||
|
||||
def project():
|
||||
return {p["label"]: p for p in hub_server["client"].get("/projects")["projects"]}["AWPCA"]
|
||||
|
||||
return SimpleNamespace(hub=hub, wiki=wiki, client=hub_server["client"], base=hub_server["base"], rows=db_rows,
|
||||
enqueue=enqueue, claim=claim, report=report, run=run, project=project,
|
||||
copy=tmp_path / "wiki" / "AWPCA", wiki_dir=tmp_path / "wiki", db_path=tmp_path / "hub.sqlite",
|
||||
jira=hub_server["agent"])
|
||||
|
||||
|
||||
def first_sync(api):
|
||||
done = api.run(TREE, CODES)
|
||||
assert done["status"] == "done"
|
||||
return done
|
||||
|
||||
|
||||
def test_first_sync(api):
|
||||
first_sync(api)
|
||||
assert (api.copy / "АРМ КА" / "index.md").is_file()
|
||||
assert (api.copy / "АРМ КА" / "01_Статусы" / "index.md").is_file()
|
||||
assert (api.copy / "АРМ КА" / "02_Роли_права" / "index.md").is_file()
|
||||
assert [t["code"] for t in json.loads((api.copy / "_tree.json").read_text(encoding="utf-8"))] == CODES
|
||||
text = (api.copy / "АРМ КА" / "01_Статусы" / "index.md").read_text(encoding="utf-8")
|
||||
assert text.startswith('---\ncode: "DOC-3"\ntitle: "Статусы"\n') and "\n# Статусы\n" in text and "аналитик" in text
|
||||
p = api.project()
|
||||
assert p["wiki_pages"] == 3 and p["wiki_root_title"] == "АРМ КА" and p["wiki_synced_at"] and p["wiki_sync_error"] is None
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0 # стадия очищена
|
||||
assert {r["code"]: r["path"] for r in api.rows("SELECT code, path FROM wiki_page")} == {
|
||||
"DOC-1": "АРМ КА", "DOC-3": "АРМ КА/01_Статусы", "DOC-2": "АРМ КА/02_Роли_права"}
|
||||
assert oct(api.copy.stat().st_mode & 0o777) == "0o700" and oct(api.wiki_dir.stat().st_mode & 0o777) == "0o700"
|
||||
assert oct((api.copy / "АРМ КА" / "01_Статусы").stat().st_mode & 0o777) == "0o700"
|
||||
|
||||
|
||||
def test_failed_keeps_copy(api):
|
||||
first_sync(api)
|
||||
api.enqueue()
|
||||
api.run(None, status="failed", error="Eva: HTTP 503")
|
||||
p = api.project()
|
||||
assert p["wiki_sync_error"] == "Eva: HTTP 503" and p["wiki_pages"] == 3
|
||||
assert (api.copy / "АРМ КА" / "01_Статусы" / "index.md").is_file()
|
||||
conn = api.hub.conn()
|
||||
try:
|
||||
assert [r["code"] for r in search.query(conn, "AWPCA", "согласов", 10)] == ["DOC-3"]
|
||||
finally:
|
||||
conn.close()
|
||||
assert api.rows("SELECT failed_at FROM wiki_project")[0]["failed_at"]
|
||||
|
||||
|
||||
def test_missing_page_fails(api):
|
||||
first_sync(api)
|
||||
api.enqueue()
|
||||
done = api.run([TREE[0]], ["DOC-1", "DOC-2"])
|
||||
assert done["status"] == "failed" and done["error"] == "нет страницы DOC-2"
|
||||
assert api.project()["wiki_sync_error"] == "нет страницы DOC-2"
|
||||
assert (api.copy / "АРМ КА" / "02_Роли_права" / "index.md").is_file()
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0
|
||||
|
||||
|
||||
def test_page_removed(api):
|
||||
shot = page("DOC-3", "Статусы", "DOC-1", 1, '<p>см. <img src="/files/x/scheme.png"></p>',
|
||||
[{"ref": "/files/x/scheme.png", "url": "https://eva.example.ru/files/x/scheme.png", "name": "scheme.png"}])
|
||||
api.run([TREE[0], TREE[1], shot], CODES)
|
||||
assert len(api.rows("SELECT * FROM wiki_attachment")) == 1
|
||||
api.enqueue()
|
||||
api.run([TREE[0], page("DOC-3", "Статусы", "DOC-1", 1, "<p>без картинки</p>")], ["DOC-1", "DOC-3"])
|
||||
assert not list(api.copy.rglob("*Роли*"))
|
||||
assert api.rows("SELECT code FROM wiki_page ORDER BY code") == [{"code": "DOC-1"}, {"code": "DOC-3"}]
|
||||
assert api.rows("SELECT * FROM wiki_attachment") == []
|
||||
conn = api.hub.conn()
|
||||
try:
|
||||
assert search.query(conn, "AWPCA", "Роли", 10) == []
|
||||
assert api.project()["wiki_pages"] == 2
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _with_file(text="см. файл"):
|
||||
att = [{"ref": "/files/x/model.xlsx", "url": "https://eva.example.ru/files/x/model.xlsx", "name": "model.xlsx"}]
|
||||
return [TREE[0], page("DOC-3", "Статусы", "DOC-1", 1, f'<p>{text} <a href="/files/x/model.xlsx">model</a></p>', att)]
|
||||
|
||||
|
||||
def test_attachment_id_survives(api):
|
||||
api.run(_with_file(), ["DOC-1", "DOC-3"])
|
||||
(a,) = api.rows("SELECT id, state FROM wiki_attachment")
|
||||
md = (api.copy / "АРМ КА" / "01_Статусы" / "index.md").read_text(encoding="utf-8")
|
||||
assert f"[📎 model.xlsx](attachment:{a['id']})" in md and a["state"] == "none"
|
||||
c = api.hub.conn()
|
||||
c.execute("UPDATE wiki_attachment SET state = 'ready', file = 'ab', size = 5 WHERE id = ?", (a["id"],))
|
||||
c.close()
|
||||
api.enqueue()
|
||||
api.run(_with_file("новый текст"), ["DOC-1", "DOC-3"])
|
||||
(b,) = api.rows("SELECT id, state, file FROM wiki_attachment")
|
||||
assert (b["id"], b["state"], b["file"]) == (a["id"], "ready", "ab")
|
||||
assert f"(attachment:{a['id']})" in (api.copy / "АРМ КА" / "01_Статусы" / "index.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_stale_root_discarded(api):
|
||||
cmd = api.claim()
|
||||
assert cmd["params"]["root"] == "DOC-1"
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0]]})
|
||||
api.client.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
|
||||
done = api.report(cmd, "done", {"root_title": "X", "codes": ["DOC-1"]})
|
||||
assert done["status"] == "done" and done["result"]["discarded"] == "корень сменился"
|
||||
assert not api.copy.exists() and api.rows("SELECT * FROM wiki_page") == []
|
||||
p = api.project()
|
||||
assert p["wiki_root"] == "DOC-21" and p["wiki_pages"] == 0 and p["wiki_synced_at"] is None
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0
|
||||
assert api.rows("SELECT failed_at FROM wiki_project")[0]["failed_at"] # часы автосинхронизации сдвинуты
|
||||
|
||||
|
||||
def test_redelivery_clears_stage(api):
|
||||
cmd = api.claim()
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0]]})
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 1
|
||||
again = api.claim()
|
||||
assert again["id"] == cmd["id"] and api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0
|
||||
done = api.report(again, "done", {"root_title": "X", "codes": ["DOC-1"]})
|
||||
assert done["status"] == "failed" and done["error"] == "нет страницы DOC-1"
|
||||
|
||||
|
||||
def test_pages_wrong_agent(api):
|
||||
cmd = api.claim()
|
||||
with pytest.raises(HubError) as e:
|
||||
api.jira.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0]]})
|
||||
assert e.value.status == 401
|
||||
|
||||
|
||||
def test_pages_not_claimed(api):
|
||||
(row,) = api.rows("SELECT id FROM command WHERE type = 'wiki_sync'")
|
||||
with pytest.raises(HubError) as e:
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": row["id"], "pages": [TREE[0]]})
|
||||
assert e.value.status == 409 and e.value.error == "not_claimed"
|
||||
|
||||
|
||||
def test_bad_page(api):
|
||||
cmd = api.claim()
|
||||
bad = {k: v for k, v in TREE[1].items() if k != "title"}
|
||||
with pytest.raises(HubError) as e:
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0], bad]})
|
||||
assert e.value.status == 422 and e.value.error == "bad_pages" and e.value.detail == "DOC-2: title"
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0 # пачка не принята целиком
|
||||
for field, value in (("orderno", "1"), ("parent_code", 5), ("html", None)):
|
||||
with pytest.raises(HubError) as e:
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0] | {field: value}]})
|
||||
assert e.value.status == 422 and e.value.detail == f"DOC-1: {field}"
|
||||
|
||||
|
||||
def test_layout_names_safe(tmp_path):
|
||||
titles = ["Корень", "../x", "a/b", "😀" * 200, "Статусы", "Статусы", "..", " . ", 'a<b>:"c|?*']
|
||||
pages = [{"code": f"D{i}", "title": t, "markdown": "текст\n", "attachments": [], "orderno": i,
|
||||
"parent_code": None if i == 0 else "D0", "eva_id": None, "url": None} for i, t in enumerate(titles)]
|
||||
target = tmp_path / "t"
|
||||
rows = sync.build_copy(pages, [p["code"] for p in pages], target, {})
|
||||
paths_ = [r["path"] for r in rows]
|
||||
for r in rows:
|
||||
assert (target / r["path"] / "index.md").resolve().is_relative_to(target.resolve())
|
||||
assert ".." not in r["path"].split("/") and all(len(part.encode()) <= 255 for part in r["path"].split("/"))
|
||||
assert "Корень/04_Статусы" in paths_ and "Корень/05_Статусы_2" in paths_
|
||||
assert "Корень/06_untitled" in paths_ and "Корень/07_untitled_2" in paths_
|
||||
assert "Корень/02_a_b" in paths_ and "Корень/01__x" in paths_
|
||||
assert sorted(p.name for p in (target / "Корень").iterdir() if p.is_dir()) == sorted(p.split("/")[1] for p in paths_[1:])
|
||||
|
||||
|
||||
def test_layout_orphans_and_cycles(tmp_path):
|
||||
def mk(code, parent, order=None):
|
||||
return {"code": code, "title": code, "markdown": "x\n", "attachments": [], "orderno": order, "parent_code": parent,
|
||||
"eva_id": None, "url": None}
|
||||
pages = [mk("R", None), mk("A", "R", 2), mk("B", "R"), mk("C", "GONE"), mk("X", "Y"), mk("Y", "X")]
|
||||
rows = sync.build_copy(pages, [p["code"] for p in pages], tmp_path / "t", {})
|
||||
assert {r["code"] for r in rows} == {"R", "A", "B", "C", "X", "Y"} and len(rows) == 6 # никто не потерян
|
||||
assert [r["code"] for r in rows if r["depth"] == 1][:2] == ["A", "B"] # orderno, затем None по коду
|
||||
|
||||
|
||||
def test_finish_pending_swap(tmp_path):
|
||||
root = tmp_path / "wiki"
|
||||
(root / ".AWPCA.new" / "x").mkdir(parents=True)
|
||||
(root / ".AWPCA.old").mkdir()
|
||||
(root / ".B.new").mkdir()
|
||||
(root / "B").mkdir() # остаток неудачной сборки рядом с живой копией
|
||||
sync.finish_pending_swaps(root)
|
||||
assert (root / "AWPCA" / "x").is_dir() and not (root / ".AWPCA.old").exists() and not (root / ".AWPCA.new").exists()
|
||||
assert (root / "B").is_dir() and not (root / ".B.new").exists()
|
||||
sync.finish_pending_swaps(root / "нет") # нет каталога — не ошибка
|
||||
|
||||
|
||||
def test_search_prefix(open_db):
|
||||
conn = open_db()
|
||||
conn.execute("INSERT INTO project(label, name, created_at) VALUES ('AWPCA', 'A', '2026-01-01')")
|
||||
assert search.ensure(conn) is True and search.ensure(conn) is True
|
||||
search.replace(conn, "AWPCA", [("DOC-3", "Статусы", "статус «На согласовании» ставит аналитик"),
|
||||
("DOC-4", "Роли", "роль администратора")])
|
||||
search.replace(conn, "OTHER", [("DOC-9", "Чужая", "согласование чужого проекта")])
|
||||
conn.execute("INSERT INTO wiki_page(label, code, title, path) VALUES ('AWPCA', 'DOC-3', 'Статусы', 'A/01_Статусы')")
|
||||
(hit,) = search.query(conn, "AWPCA", "согласов", 10)
|
||||
assert hit["code"] == "DOC-3" and "На согласовании" in hit["snippet"] and hit["path"] == "A/01_Статусы"
|
||||
assert search.query(conn, "AWPCA", 'согласов"', 10)[0]["code"] == "DOC-3" # кавычка не роняет
|
||||
for q in ('"', "", "* AND (", "NEAR(", "статус ОR"):
|
||||
assert isinstance(search.query(conn, "AWPCA", q, 5), list)
|
||||
assert [r["code"] for r in search.query(conn, "AWPCA", "роль админ", 10)] == ["DOC-4"]
|
||||
assert search.query(conn, "AWPCA", "чужого", 10) == []
|
||||
|
||||
|
||||
def test_search_without_fts_uses_files(api, monkeypatch):
|
||||
first_sync(api)
|
||||
c = api.hub.conn()
|
||||
try:
|
||||
c.execute("DROP TABLE wiki_page_fts")
|
||||
(hit,) = search.query(c, "AWPCA", "согласов", 10)
|
||||
assert hit["code"] == "DOC-3" and "На согласовании" in hit["snippet"]
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
# --- пункты контроллера ---------------------------------------------------------
|
||||
|
||||
def test_failed_at_prevents_tick_storm(api):
|
||||
"""Любая неудача пишет failed_at, и автотик не ставит новый пакет сразу."""
|
||||
def tick_once():
|
||||
c = api.hub.conn()
|
||||
try:
|
||||
tick.auto_sync_tick(None, c)
|
||||
finally:
|
||||
c.close()
|
||||
return api.rows("SELECT COUNT(*) AS n FROM batch WHERE project = 'AWPCA'")[0]["n"]
|
||||
|
||||
first_sync(api)
|
||||
assert api.rows("SELECT synced_at, failed_at FROM wiki_project")[0]["synced_at"]
|
||||
assert tick_once() == 1 # свежая успешная
|
||||
c = api.hub.conn()
|
||||
c.execute("UPDATE wiki_project SET synced_at = ?, failed_at = NULL", ((db.utcnow() - timedelta(hours=30)).isoformat(),))
|
||||
c.close()
|
||||
# failed: пакет выдан, агент отчитался failed
|
||||
api.enqueue()
|
||||
api.run(None, status="failed", error="Eva: HTTP 503")
|
||||
assert tick_once() == 2
|
||||
# отброшенная
|
||||
c = api.hub.conn()
|
||||
c.execute("UPDATE wiki_project SET synced_at = ?, failed_at = NULL", ((db.utcnow() - timedelta(hours=30)).isoformat(),))
|
||||
c.close()
|
||||
api.enqueue()
|
||||
claimed = api.claim()
|
||||
api.client.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
|
||||
api.report(claimed, "done", {"root_title": "X", "codes": ["DOC-1"]})
|
||||
assert api.rows("SELECT failed_at FROM wiki_project")[0]["failed_at"]
|
||||
# отменённая
|
||||
c = api.hub.conn()
|
||||
c.execute("UPDATE wiki_project SET synced_at = ?, failed_at = NULL", ((db.utcnow() - timedelta(hours=30)).isoformat(),))
|
||||
queued = api.rows("SELECT id FROM command WHERE type = 'wiki_sync' AND status = 'queued'")
|
||||
for q in queued:
|
||||
queue.cancel_command(c, q["id"])
|
||||
c.close()
|
||||
c = api.hub.conn()
|
||||
try:
|
||||
tick.housekeeping_tick(None, c)
|
||||
assert api.rows("SELECT failed_at FROM wiki_project")[0]["failed_at"]
|
||||
before = api.rows("SELECT COUNT(*) AS n FROM batch")[0]["n"]
|
||||
tick.auto_sync_tick(None, c)
|
||||
assert api.rows("SELECT COUNT(*) AS n FROM batch")[0]["n"] == before
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def test_fill_ids_error_becomes_failed(api):
|
||||
cmd = api.claim()
|
||||
bad = {"code": "DOC-1", "eva_id": None, "title": "АРМ КА", "parent_code": None, "orderno": None, "url": None,
|
||||
"markdown": "[📎 f](attachment:@5)\n", "attachments": []}
|
||||
c = api.hub.conn()
|
||||
c.execute("INSERT INTO wiki_sync_stage VALUES (?, 'DOC-1', ?)", (cmd["id"], json.dumps(bad)))
|
||||
c.close()
|
||||
done = api.report(cmd, "done", {"root_title": "X", "codes": ["DOC-1"]})
|
||||
assert done["status"] == "failed" and done["error"].startswith("DOC-1: ") and "вне списка" in done["error"]
|
||||
assert api.project()["wiki_sync_error"].startswith("DOC-1: ") and not api.copy.exists()
|
||||
assert not list(api.wiki_dir.glob(".*")) # .new убран
|
||||
|
||||
|
||||
def test_long_name_failed_not_crash(hub_server, tmp_path):
|
||||
label = "/" * 100 # dir_name даёт 300 символов
|
||||
hub_server["client"].post("/projects", {"label": label, "name": "длинная", "wiki_root": "DOC-1"})
|
||||
hub = hub_server["hub"]
|
||||
conn = hub.conn()
|
||||
token = settings.tokens(conn)["agents"]["wiki"]
|
||||
conn.close()
|
||||
wiki = HubClient(hub_server["base"], token)
|
||||
cmd = wiki.post("/agent/claim", {})["command"]
|
||||
wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0]]})
|
||||
done = wiki.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"root_title": "X", "codes": ["DOC-1"]}})
|
||||
assert done["status"] == "failed" and done["error"].startswith("не удалось собрать копию")
|
||||
conn = hub.conn()
|
||||
try:
|
||||
row = conn.execute("SELECT sync_error, failed_at FROM wiki_project WHERE label = ?", (label,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row["sync_error"].startswith("не удалось собрать копию") and row["failed_at"]
|
||||
|
||||
|
||||
def test_reset_root_clears_fts_and_dir(api):
|
||||
first_sync(api)
|
||||
c = api.hub.conn()
|
||||
assert c.execute("SELECT COUNT(*) AS n FROM wiki_page_fts").fetchone()["n"] == 3
|
||||
c.close()
|
||||
api.client.patch("/projects/AWPCA", {"wiki_root": None})
|
||||
c = api.hub.conn()
|
||||
try:
|
||||
assert c.execute("SELECT COUNT(*) AS n FROM wiki_page_fts").fetchone()["n"] == 0
|
||||
assert c.execute("SELECT COUNT(*) AS n FROM wiki_page").fetchone()["n"] == 0
|
||||
finally:
|
||||
c.close()
|
||||
assert not api.copy.exists()
|
||||
|
||||
|
||||
def test_drop_copy_logs_rmtree_failure(api, monkeypatch, caplog):
|
||||
first_sync(api)
|
||||
real = projects.shutil.rmtree
|
||||
|
||||
def boom(path, *a, **k):
|
||||
if "AWPCA" in str(path):
|
||||
raise PermissionError("нельзя")
|
||||
return real(path, *a, **k)
|
||||
|
||||
monkeypatch.setattr(projects.shutil, "rmtree", boom)
|
||||
with caplog.at_level(logging.WARNING, logger=projects.__name__):
|
||||
api.client.patch("/projects/AWPCA", {"wiki_root": None})
|
||||
assert "не удалён" in caplog.text and "нельзя" in caplog.text
|
||||
|
||||
|
||||
def test_no_heavy_work_under_write_lock(api, monkeypatch):
|
||||
"""Перевод HTML и запись файлов идут вне BEGIN IMMEDIATE: параллельное соединение может открыть запись."""
|
||||
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_build = convert.to_markdown, sync.build_copy
|
||||
monkeypatch.setattr(stage.convert, "to_markdown", lambda *a, **k: (probe("markdown"), real_md(*a, **k))[1])
|
||||
monkeypatch.setattr(sync, "build_copy", lambda *a, **k: (probe("files"), real_build(*a, **k))[1])
|
||||
first_sync(api)
|
||||
assert ("markdown", "free") in seen and ("files", "free") in seen and not [s for s in seen if s[1] == "locked"]
|
||||
|
||||
|
||||
def test_swap_failure_rolls_back_rows(api, monkeypatch):
|
||||
first_sync(api)
|
||||
api.enqueue()
|
||||
|
||||
def boom(cur, new, old):
|
||||
raise OSError("диск полон")
|
||||
|
||||
monkeypatch.setattr(sync, "_swap", boom)
|
||||
done = api.run([TREE[0], page("DOC-3", "Новая", "DOC-1", 1)], ["DOC-1", "DOC-3"])
|
||||
assert done["status"] == "failed" and "не удалось подменить копию" in done["error"]
|
||||
assert api.project()["wiki_pages"] == 3 and "диск полон" in api.project()["wiki_sync_error"]
|
||||
assert [r["code"] for r in api.rows("SELECT code FROM wiki_page ORDER BY code")] == ["DOC-1", "DOC-2", "DOC-3"]
|
||||
assert (api.copy / "АРМ КА" / "01_Статусы" / "index.md").is_file() and not list(api.wiki_dir.glob(".*"))
|
||||
|
||||
|
||||
def test_batch_limit_and_replace(api):
|
||||
cmd = api.claim()
|
||||
with pytest.raises(HubError) as e:
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [page(f"DOC-{i}", "t") for i in range(51)]})
|
||||
assert e.value.status == 422
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0]]})
|
||||
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": [TREE[0] | {"title": "Новый"}]}) # повтор — замена
|
||||
assert [json.loads(r["payload"])["title"] for r in api.rows("SELECT payload FROM wiki_sync_stage")] == ["Новый"]
|
||||
Loading…
x
Reference in New Issue
Block a user