From 487391eddc2ad746ca0e801321ae7198ce587458 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:30:41 +0400 Subject: [PATCH 01/15] =?UTF-8?q?docs:=20change=20eva-agent-mvp=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-eva-agent-mvp.md | 202 ++++++++++++++++++ openspec/changes/eva-agent-mvp/.openspec.yaml | 2 + openspec/changes/eva-agent-mvp/design.md | 111 ++++++++++ openspec/changes/eva-agent-mvp/proposal.md | 17 ++ 4 files changed, 332 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-eva-agent-mvp.md create mode 100644 openspec/changes/eva-agent-mvp/.openspec.yaml create mode 100644 openspec/changes/eva-agent-mvp/design.md create mode 100644 openspec/changes/eva-agent-mvp/proposal.md diff --git a/docs/superpowers/plans/2026-10-24-eva-agent-mvp.md b/docs/superpowers/plans/2026-10-24-eva-agent-mvp.md new file mode 100644 index 0000000..4ab7354 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-eva-agent-mvp.md @@ -0,0 +1,202 @@ +# eva-agent MVP — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** агент на виртуалке банка выполняет команды модуля `wiki` хаба: `wiki_sync` (дерево EVA Wiki под корнем → пачки страниц хабу) и `wiki_fetch` (вложение до 10 МБ → хранилище хаба), с журналом, транспортом через общую папку и самообновлением, как jira-agent. + +**Architecture:** один файл `src/agent.py` (Python 3.9, stdlib) — копия общих частей jira-agent v14 (HTTP, `FolderTransport`, `HubClient`, `Journal`, `Runner`, `Updater`, CLI) плюс `EvaClient` (JSON-RPC 2.2), `LinkParser` (классификация ссылок HTML) и `Executor` с `do_wiki_sync`/`do_wiki_fetch`; `src/run.py` — дословная копия запускателя. + +**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`. + +**Spec:** контракт — `../../jira-hub/.wt/wiki/openspec/changes/wiki-module/` (worktree хаба, ветка `feat/91-wiki`, PR jira-hub#92): `specs/wiki/spec.md` («Синхронизация вики», «Вложения вики по запросу»), `design.md` решения 10, 11, 14 и Context; решения агента — `openspec/changes/eva-agent-mvp/design.md` этого репозитория (1–11). Код хаба, принимающий агента: `src/jira_hub/modules/wiki/{stage.py,sync.py,attachments.py,commands.py}` в том же worktree хаба. + +## Global Constraints + +- Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ (`match`, `X | Y` в аннотациях функций) и сторонние импорты запрещены — ловит `tests/test_py39.py`. +- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный весь набор после каждой задачи. +- `AGENT_VERSION = 1` — ровно одна строка `AGENT_VERSION = <целое>` в `src/agent.py`. +- `src/run.py` — байт в байт `../../jira-agent/src/run.py` (сверка `cmp` в тесте `test_run_py_is_copy`, путь к jira-agent — `../../jira-agent/src/run.py` от корня worktree; если файла нет — тест пропускается с причиной). +- Токен Eva — только в `agent.json` (не в git: `.gitignore` получает `agent.json`, `*.jsonl`, `update.json`, `*.lock`); в логах и отчётах его нет (тест `test_token_not_logged`). +- Протокол хаба: `POST /agent/claim`, `GET /agent/settings`, `GET /agent/commands/{id}`, `POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited}`, `GET /agent/release`, `POST /agent/files {content}` → `{file, size}`, `POST /agent/wiki/pages {command_id, pages}`. Форма страницы — `stage._check` хаба. +- Не трогать репозитории `jira-agent` (только читать), `jira-hub` (только читать и запускать тестовый хаб во временном `JIRA_HUB_HOME`), живой хаб и `~/.local/share/jira-hub`. +- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Дерево Eva с циклом (страница — родитель сама себе или через `tree_parent`) и страница с двумя родителями — обход конечен, страница в отчёте один раз: `test_tree_cycle_and_two_parents` (Task 4). +2. HTML с относительными и абсолютными адресами, миниатюрами `.meta/thumbnail.jpg`, `static/`, чужим хостом, `DOC-` в адресе другого хоста — классификация как в design 6: `tests/test_links.py` (Task 3). +3. Eva отвечает 429 с `Retry-After`, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговый `failed` с текстом без токена: `tests/test_eva_client.py` (Task 2). +4. Вложение без `Content-Length` и больше 10 МБ — чтение обрывается на 10 МБ + 1 байт, `failed` с текстом спеки: `test_fetch_too_big_streaming` (Task 5). +5. Перезапуск агента посреди `wiki_sync` (журнал: started без finished) — команда выполняется заново, хаб получает полный набор пачек и один отчёт: `test_recover_resyncs` (Task 4). + +--- + +### Task 1: Каркас — общие части jira-agent, конфиг, запускатель + +**Files:** +- Create: `src/agent.py`, `src/run.py` (копия), `src/agent.example.json`, `tests/{__init__.py,_common.py,fake_hub.py,fake_relay.py,test_py39.py,test_clients.py,test_folder.py,test_journal.py,test_runner.py,test_update.py,test_launcher.py,test_self_update_e2e.py}`, `.gitignore` (дописать) +- Modify: `CLAUDE.md`, `ARCHITECTURE.md` (заполнить шаблоны по образцу `../../jira-agent/CLAUDE.md` и `ARCHITECTURE.md`, для eva-agent), `README.md` (создать: назначение, установка — Task 6 допишет) + +**Interfaces:** +- Produces: + - `load_config(path) -> dict` (обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; `folder:` — абсолютный путь; умолчания как у jira-agent). + - Копия из jira-agent без изменений смысла: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `FolderTransport`, `Journal`, `Updater`, `acquire_lock`, `launcher_version`, `cmd_run`, `cmd_prune`, `main`. + - `HubClient` — без jira-маршрутов (`state`, `known_keys`, `history`), плюс `wiki_pages(command_id, pages) -> dict`. + - `Runner` — вызывает `Executor.run(cmd) -> dict` (`{status, result, error, jira_calls, rate_limited}`), как в jira-agent. + - `Executor(eva, hub, settings)` с `do_wiki_sync`/`do_wiki_fetch`, в этой задаче — `failed` «не реализовано». + - `cmd_check(cfg) -> int` проверяет только хаб; Eva добавит Task 6. +- Тесты общих частей переносятся из `../../jira-agent/tests/` с заменой: Jira → нет, `ensure_*` → команда-заглушка `wiki_fetch` с `failed`. Сценарии журнала, папки, 401/403-паузы, самообновления и запускателя сохраняются. + +- [ ] **Step 1:** перенести тесты общих частей, они упадут — `agent.py` нет. +- [ ] **Step 2:** собрать `src/agent.py` из jira-agent v14: + - вырезать `JiraClient`, jira-методы `Executor`, `ConfigGap` и проверки Jira; + - `AGENT_VERSION = 1`; + - `run.py` скопировать. +- [ ] **Step 3:** весь набор — PASS; `test_run_py_is_copy` и `test_py39` зелёные. +- [ ] **Step 4: Commit** «Каркас eva-agent: общие части jira-agent v14 (#1)». + +--- + +### Task 2: Клиент Eva + +**Files:** +- Modify: `src/agent.py` (класс `EvaClient(Http)` после `HubClient`) +- Create: `tests/fake_eva.py`, `tests/test_eva_client.py` + +**Interfaces:** +- Produces: + - `EvaClient(cfg_eva: dict, timeout, verify_ssl, ca_bundle)`, его методы: + - `call(method, **kwargs) -> Any` — `POST /api/?m=`, `Bearer`; повторы по design 4; `error` в ответе — `EvaError(": ")`; + - `get_doc(code, fields) -> dict | None`; + - `list_docs(flt, fields) -> list[dict]` — страницы по 200 через `slice`; обёрнутый результат `{items|result}` разворачивается; + - `download(url, limit) -> bytes` — поток с обрывом на `limit + 1`; больше лимита — `TooBig`. + - Свойства: `calls` (число вызовов), `rate_limited` (число 429), `pacing` (пауза, с; ставит Runner из `wiki_pacing`). + - `tests/fake_eva.py: FakeEva` — `http.server` на порту 0: + - хранит документы `{id, code, name, parent, tree_parent, project, orderno, text}`; + - поддерживает фильтры `code ==`, `project.code ==`, `tree_parent.code ==`, `slice`; + - сценарии сбоев: `fail_next(status, times, retry_after=None)`, `rpc_error_next(text)`; + - файлы `/files/...` с `Content-Length` или без него; + - проверяет `Authorization`. + +- [ ] **Step 1: Падающие тесты:** + - `test_call_ok`; + - `test_retry_429_retry_after` — ждал `Retry-After` через подменённый `sleep`; + - `test_retry_503_then_ok`; + - `test_gives_up_after_4`; + - `test_rpc_error`; + - `test_list_pages_200` — 450 документов за три вызова; + - `test_pacing_between_calls`; + - `test_download_limit` — без `Content-Length`, 10 МБ + 1; + - `test_token_not_logged` — токен не встречается ни в логе, ни в тексте ошибок. +- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.** +- [ ] **Step 5: Commit** «Клиент Eva JSON-RPC (#1)». + +--- + +### Task 3: Разбор ссылок страницы + +**Files:** +- Modify: `src/agent.py` (функция `classify_links(html, base_url) -> tuple[list, list]` и `class _LinkParser(HTMLParser)`) +- Create: `tests/test_links.py`, `tests/fixtures/pages/*.html` (синтетические, форматы адресов из design хаба, Context; текст ТЗ не копировать) + +**Interfaces:** +- Produces: `classify_links(html: str, base_url: str) -> (attachments: [{ref, url, name}], links: [{ref, code}])` по design 6–7, в порядке появления, без склейки повторов. + +- [ ] **Step 1: Падающие тесты:** + +```python +def test_image_relative_files() # src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A/img.png?version=1" → + # url "https://eva.test/files/obj/…/img.png" (без query), name "img.png" +def test_thumbnail_maps_to_file() # ".../model.xlsx.meta/thumbnail.jpg?version=1" → url ".../model.xlsx", name из alt или "model.xlsx" +def test_file_link_same_url() # model.xlsx → тот же url, что у миниатюры +def test_static_ignored() # src="static/attach-file.png" → ни в одном списке +def test_page_link_relative() # href="project/Document/DOC-019834" → links [{ref, code: "DOC-019834"}] +def test_foreign_host() # href="https://other.test/files/x.png" и "https://other.test/project/Document/DOC-1" → пусто +def test_name_unquoted() # ".../%D0%9F%D0%9C%D0%9F.xlsx" без alt и текста → name "ПМП.xlsx" +def test_ref_exact() # ref равен атрибуту байт в байт (с & → как отдаёт HTMLParser: unescape; проверить, что хаб ищет так же — см. convert.py хаба) +``` + + Для `test_ref_exact` надо сверить, с чем хаб сравнивает `ref`: с сырым значением атрибута или с unescape. Это видно в `../../jira-hub/.wt/wiki/src/jira_hub/modules/wiki/convert.py`. Записать, как сделано, в отчёт и в design (решение 12). +- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.** +- [ ] **Step 5: Commit** «Разбор ссылок страниц Eva (#1)». + +--- + +### Task 4: Команда `wiki_sync` + +**Files:** +- Modify: `src/agent.py` (`Executor.do_wiki_sync`, функции `build_children(docs)`, `walk(root, children)`), `tests/fake_hub.py` (`POST /agent/wiki/pages` — записывает пачки; проверка формы по образцу `stage._check` хаба: `code`, `title`, `html` строки; ≤ 50 страниц) +- Create: `tests/test_wiki_sync.py` + +**Interfaces:** +- Consumes: `EvaClient` (Task 2), `classify_links` (Task 3), `HubClient.wiki_pages` (Task 1). +- Produces: `Executor.do_wiki_sync(params {label, root}) -> {status, result {root_title, codes, eva_calls}, error, jira_calls, rate_limited}` по design 5; пачки — `MAX_PAGES = 50`, `MAX_BATCH_BYTES = 4 * 1024 * 1024`. + +- [ ] **Step 1: Падающие тесты:** + +```python +def test_sync_tree_order() # корень DOC-1 (project P), дети DOC-3 (orderno 1), DOC-2 (orderno 2), внук DOC-4 под DOC-3 → + # codes [DOC-1, DOC-3, DOC-4, DOC-2]; parent_code; url ".../project/Document/DOC-3" +def test_sync_batches_50() # 120 страниц → пачки 50, 50, 20; codes — 120 +def test_sync_batch_bytes() # страницы по 1,5 МБ → пачка не больше 4 МБ +def test_tree_cycle_and_two_parents() # A.parent=B, B.tree_parent=A; C с parent=корень и tree_parent=DOC-3 → каждый код один раз +def test_root_without_project() # у корня нет project → обход через tree_parent.code +def test_root_missing() # get_doc → None → failed «страница DOC-9 не найдена или нет прав» +def test_eva_failure_reported() # Eva 503 четыре раза подряд → failed, текст содержит "503", токена в тексте нет +def test_hub_rejects_batch() # fake_hub 422 на пачку → failed с detail хаба +def test_recover_resyncs() # журнал started без finished, команда claimed в хабе → выполняется заново: полные пачки, один done +def test_counts() # jira_calls == eva.calls, rate_limited == числу 429 +``` + +- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.** +- [ ] **Step 5: Commit** «Команда wiki_sync (#1)». + +--- + +### Task 5: Команда `wiki_fetch` + +**Files:** +- Modify: `src/agent.py` (`Executor.do_wiki_fetch`) +- Create: `tests/test_wiki_fetch.py` + +**Interfaces:** +- Produces: `Executor.do_wiki_fetch(params {attachment_id, url}) -> {status, result {file, size}, …}` по design 8; использует `HubClient.upload_file` (как у jira-agent `fetch_attachment`). + +- [ ] **Step 1: Падающие тесты:** + - `test_fetch_ok` — 300 КБ; `done`, `size` 307200; файл у fake_hub совпадает побайтно; + - `test_fetch_foreign_host` — `failed` «чужой хост», у fake_eva нет запросов; + - `test_fetch_too_big_header` — `Content-Length` 11 МБ, тело не читается; + - `test_fetch_too_big_streaming` — без `Content-Length`; + - `test_fetch_404` — «файл не найден в Eva»; + - `test_fetch_empty_file` — 0 байт; `done`, `size` 0: хаб принимает пустой файл от агента. +- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.** +- [ ] **Step 5: Commit** «Команда wiki_fetch (#1)». + +--- + +### Task 6: `check`, документация, сквозной прогон с хабом + +**Files:** +- Modify: `src/agent.py` (`cmd_check` — хаб и Eva по design 10), `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/eva-agent-mvp/design.md` (решения исполнителей), `src/agent.example.json` +- Create: `scripts/e2e-hub.sh`, `tests/test_check.py` + +**Interfaces:** +- `scripts/e2e-hub.sh`: + - поднимает тестовый хаб из `../../jira-hub/.wt/wiki`: `uv run --project <путь> jira-hub serve --port 0`, неудобно — тогда фиксированный свободный порт, например 8795, с `JIRA_HUB_HOME=$(mktemp -d)`; + - поднимает `FakeEva` с деревом из 5 страниц, картинкой и файлом; + - заводит проект `E2E` с `wiki_root` через `POST /projects` клиентским токеном из `jira-hub tokens`; + - запускает агент `src/agent.py run` с токеном агента `wiki` до первого `done`; нужен флаг `--once` или тайм-аут — решает исполнитель, без изменения протокола; + - проверяет `GET /wiki/tree?project=E2E` (5 страниц), `GET /wiki/pages/<код>` (Markdown со ссылкой `attachment:`), затем `POST /wiki/attachments//fetch` и `GET …/content`; + - печатает «e2e: ok»; хаб и фейк гасятся по `trap`. +- README — установка на виртуалку: + - каталог рядом с jira-agent (например `C:\…\eva-agent\` или `~/eva-agent/`); + - файлы `agent.py`, `run.py`, `agent.json` (из примера: `hub_url` = тот же `folder:<путь>`, что у jira-agent, `hub_token` = «агент wiki» из `jira-hub tokens`, `eva.base_url`, `eva.token` — профиль Eva → Безопасность); + - `python3 agent.py check`, затем `python3 run.py`; + - автозапуск — как у jira-agent; + - выкатка новых версий — `uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py`. + +- [ ] **Step 1:** тесты `test_check` (хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS. +- [ ] **Step 2:** `bash scripts/e2e-hub.sh` → «e2e: ok». Хаб — только из worktree `feat/91-wiki` и только во временном `JIRA_HUB_HOME`. +- [ ] **Step 3:** документация. +- [ ] **Step 4:** весь набор — PASS. +- [ ] **Step 5: Commit** «check, README, сквозной прогон с хабом (#1)». diff --git a/openspec/changes/eva-agent-mvp/.openspec.yaml b/openspec/changes/eva-agent-mvp/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/eva-agent-mvp/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md new file mode 100644 index 0000000..fadd285 --- /dev/null +++ b/openspec/changes/eva-agent-mvp/design.md @@ -0,0 +1,111 @@ +# eva-agent-mvp — решения агента + +Спека и дизайн — в `jira-hub/openspec/changes/wiki-module/` (issue aep/jira-hub#91, PR aep/jira-hub#92): +- решения 10, 11, 14 и Context (форматы ссылок Eva); +- требования `wiki` «Синхронизация вики» и «Вложения вики по запросу»; +- `modules` «Агенты модулей»; +- `hub-api` «Маршруты агента» (общие `/agent/*`). + +Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль `wiki`: имя агента в хабе `wiki`, токен — +`jira-hub tokens` → «агент wiki». + +Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все +решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion». + +## Context + +- **Источник для копирования — jira-agent v14** (`../jira-agent/src/agent.py`, `run.py`). Его части, не связанные с + Jira, отлажены с v2 по v14: + - HTTP-слой: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect`; + - транспорт `FolderTransport` и клиент хаба `HubClient`; + - журнал `Journal`, цикл `Runner`, самообновление `Updater`; + - `acquire_lock`, CLI `run|check|selftest|prune`, `launcher_version`. +- **Клиент Eva и обход дерева — по образцу** `../eva_wiki_export.py`: + - JSON-RPC 2.2: `POST /api/?m=<метод>`, `Authorization: Bearer`, тело `{jsonrpc: "2.2", method, callid, + kwargs}`; + - повторы на 429/502/503/504 и сетевых сбоях; + - `CmfDocument.get`/`list`, страницы списка по 200; + - дерево — по `parent`/`tree_parent` из списка документов проекта (`project.code`), запасной путь — обход через + `tree_parent.code`. +- **Хаб принимает:** + - пачки `POST /agent/wiki/pages {command_id, pages}` — до 50 страниц, тело до 16 МБ (`MAX_BODY` хаба); + - форму страницы проверяет `modules/wiki/stage.py`; + - отчёт `wiki_sync done {root_title, codes, eva_calls}`: `codes[0]` должен быть кодом корня; + - отчёт `wiki_fetch done {file, size}`: файл должен лежать в хранилище хаба с этим размером. + +## Decisions made without discussion + +Дата всех решений — 2026-10-24. + +1. **Скопировать общие части jira-agent v14, а не выделять библиотеку.** + Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное + поведение. + Цена ошибки: правки общих частей придётся повторять в двух агентах. +2. **`run.py` — дословная копия запускателя jira-agent** (`LAUNCHER_VERSION = 2`), вместе с переменной окружения + `JIRA_AGENT_LAUNCHER`. + Обоснование: в протоколе самообновления важны имена `agent.py`, `update.json` и `AGENT_VERSION`; переменная — + внутреннее дело пары запускатель–агент. + Цена ошибки: переименование переменной в обоих файлах. +3. **Конфиг `agent.json`:** + - обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; + - необязательны `verify_ssl`, `ca_bundle`, `timeout` (60), `poll_seconds` (5), `auto_update` (true). + + Обоснование: формат jira-agent, где ключ `jira` заменён на `eva`. + Цена ошибки: правка шаблона и `load_config`. +4. **Пауза между вызовами Eva — `wiki_pacing` из `GET /agent/settings`** (по умолчанию 0.2 с). + - 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с. + - При 429 учитывается `Retry-After` (не больше 60 с). + - Число 429 уходит в `rate_limited` отчёта, число вызовов Eva — в `jira_calls`: поле протокола хаба, имя общее + для всех агентов. + + Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента. +5. **`wiki_sync` — порядок работы:** + 1. Корень берётся через `CmfDocument.get` по коду из `params.root`. Нет корня — `failed` «страница <код> не найдена + или нет прав». + 2. Дерево строится как в `collect_subtree`. + 3. Обход в глубину от корня с защитой от циклов (`seen` по `id`). Соседи — по `(orderno is None, orderno, name)`. + 4. По каждой странице отдельный `CmfDocument.get(fields=["code","name","text"])`. + 5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше. + 6. Отчёт `codes` — коды в порядке обхода, корень первым. + - Поля страницы: `eva_id = str(id)`, `parent_code` — код родителя по обходу (у корня `null`), `orderno` — целое или + `null`, `url = urljoin(base_url + "/", "project/Document/")`. + + Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление. +6. **Ссылки разбирает `html.parser.HTMLParser` из stdlib.** + - Берутся `img[src]` и `a[href]`. + - Адрес разрешается `urljoin(base_url + "/", ref)`. «Свой» — тот же `scheme` и `netloc`, что у `eva.base_url`. + - **Вложение** — свой адрес с путём, начинающимся на `/files/`. Из `url` убираются query и fragment. Миниатюра + `<путь>.meta/thumbnail.jpg` означает файл `<путь>`. + - **Ссылка на страницу** — свой адрес с `DOC-<цифры>` в пути (код в верхнем регистре). + - **`static/…`** и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте. + - **Имя вложения** — `alt` или `title`, затем текст ссылки, затем последний сегмент пути после `unquote`. + - Каждая ссылка передаётся отдельной записью `{ref, url, name}` или `{ref, code}`, `ref` — ровно значение + атрибута. Повторы по `url` склеивает хаб. + + Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context). + Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом. +7. **Без `?version=1` в `url` вложения.** + Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение. + Цена ошибки: если Eva без `version` не отдаёт файл, `wiki_fetch` падает; правка одной строки. +8. **`wiki_fetch`:** + - адрес не своего хоста — `failed` «чужой хост», запроса нет; + - файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт; + - `Content-Length` больше 10 МБ или прочитано больше — `failed` «файл больше 10 МБ — откройте в Eva»; + - иначе `POST /agent/files` и `done {file, size}`; + - HTTP 404 — `failed` «файл не найден в Eva». + + Цена ошибки: большие файлы только ссылкой. +9. **Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново.** Журнал нужен + только для досылки отчёта; `wiki_sync` не продолжается с середины. Хаб очищает стадию при повторной выдаче. + Цена ошибки: лишняя полная перекачка после перезапуска. +10. **`check`:** + - хаб: `GET /agent/settings` своим токеном; + - Eva: `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; + - вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent; + - `selftest` — версия плюс `check`. +11. **Тесты:** + - `unittest` на `/usr/bin/python3` (3.9); + - фейки `tests/fake_hub.py` (как у jira-agent, плюс `POST /agent/wiki/pages`), `tests/fake_eva.py` (JSON-RPC на + `http.server`), `tests/fake_relay.py`; + - `tests/test_py39.py` — копия. + - Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель. diff --git a/openspec/changes/eva-agent-mvp/proposal.md b/openspec/changes/eva-agent-mvp/proposal.md new file mode 100644 index 0000000..955ec04 --- /dev/null +++ b/openspec/changes/eva-agent-mvp/proposal.md @@ -0,0 +1,17 @@ +## Why + +Модулю `wiki` хаба (jira-hub#91, PR #92) нужен агент на виртуалке банка: Eva доступна только из сети банка, токен Eva +не должен покидать виртуалку. Решение владельца 23.10.2026: отдельный агент, не jira-agent. + +## What Changes + +- Новый агент `eva-agent`: `src/agent.py` (Python 3.9, stdlib) и `src/run.py`. Транспорт, журнал и самообновление + такие же, как у jira-agent v14. +- Команды хаба `wiki_sync` и `wiki_fetch`. Их контракт описан в `jira-hub/openspec/changes/wiki-module/specs/wiki/spec.md`. +- README по установке на виртуалку и сквозной прогон с тестовым хабом. + +## Non-Goals + +- Запись в Eva и история изменений. +- Частичная перекачка дерева (нужна дата изменения документа в Eva). +- Общая библиотека с jira-agent. -- 2.47.2 From 4749c862f7b2c9ebbd3067cafa23ca54076685b4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:31:45 +0400 Subject: [PATCH 02/15] =?UTF-8?q?=D0=A0=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=2012=E2=80=9313:=20ref=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20unescape,=20=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D1=85=D0=B0=D0=B1=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index fadd285..56a7081 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -109,3 +109,9 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге `http.server`), `tests/fake_relay.py`; - `tests/test_py39.py` — копия. - Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель. +12. **`ref` вложений и ссылок — значение атрибута после unescape** (так отдаёт `html.parser`). + Обоснование: хаб (`convert.py`, BeautifulSoup `.get("src"/"href")`) ищет запись по тому же unescaped-значению. + Цена ошибки: адреса с `&` не сопоставятся, вложение останется неразрешённым; правка одной строки. +13. **При расхождении плана с кодом хаба по протоколу прав код хаба.** Поручение владельца; расхождения фиксируются + находками `O-n`, хаб не правится. + Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e. -- 2.47.2 From 67f1a5a0961e9c072e21850c08dae1ee508d0630 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:40:33 +0400 Subject: [PATCH 03/15] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20ev?= =?UTF-8?q?a-agent:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20jira-agent=20v14=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 8 + ARCHITECTURE.md | 39 +- CLAUDE.md | 50 +- README.md | 10 + openspec/changes/eva-agent-mvp/design.md | 24 + src/agent.example.json | 13 + src/agent.py | 1084 ++++++++++++++++++++++ src/run.py | 319 +++++++ tests/__init__.py | 7 + tests/_common.py | 29 + tests/fake_hub.py | 158 ++++ tests/fake_relay.py | 74 ++ tests/test_clients.py | 254 +++++ tests/test_folder.py | 240 +++++ tests/test_journal.py | 154 +++ tests/test_launcher.py | 503 ++++++++++ tests/test_py39.py | 33 + tests/test_runner.py | 563 +++++++++++ tests/test_self_update_e2e.py | 78 ++ tests/test_update.py | 303 ++++++ 20 files changed, 3924 insertions(+), 19 deletions(-) create mode 100644 README.md create mode 100644 src/agent.example.json create mode 100644 src/agent.py create mode 100644 src/run.py create mode 100644 tests/__init__.py create mode 100644 tests/_common.py create mode 100644 tests/fake_hub.py create mode 100644 tests/fake_relay.py create mode 100644 tests/test_clients.py create mode 100644 tests/test_folder.py create mode 100644 tests/test_journal.py create mode 100644 tests/test_launcher.py create mode 100644 tests/test_py39.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_self_update_e2e.py create mode 100644 tests/test_update.py diff --git a/.gitignore b/.gitignore index 4b7e182..134e3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ .superpowers/ .bounded .DS_Store +agent.json +*.jsonl +update.json +*.lock +__pycache__/ +*.pyc +agent.py.new +agent.py.prev diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a85988..da5b7c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,15 +1,40 @@ # Архитектура -{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}} +Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в +`tests/` работают на фейках хаба и Eva. Спека: `../jira-hub/openspec/changes/wiki-module/`; решения агента: +`openspec/changes/eva-agent-mvp/design.md`. Общие части — копия jira-agent v14 (`../jira-agent/ARCHITECTURE.md`). -## Модули +## Разделы файла `src/agent.py` -| Модуль | Ответственность | Зависит от | -|---|---|---| -| | | | +| Раздел | Классы и функции | Ответственность | Что использует | +|---|---|---|---| +| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — | +| Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` | +| HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` | +| Eva | `EvaClient` | клиент Eva (Bearer-токен): счётчики вызовов и 429, время последнего вызова. Каркас; JSON-RPC и повторы — в задачах 2–3 | `Http` | +| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http` | `os`, `uuid` | +| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` | +| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `claimed` → `started` → `finished` → `acked`; восстановление; `prune` | файловая система | +| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` | +| Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` | +| Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | +| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | + +## Запускатель `src/run.py` + +Дословная копия запускателя jira-agent (`LAUNCHER_VERSION = 2`, переменная окружения `JIRA_AGENT_LAUNCHER`). Не импортирует +`agent.py`. Запускает `agent.py run` дочерним процессом; код 75 — подмена на `agent.py.new` с пробным периодом и +откатом; иначе перезапуск с паузой. Проверка идентичности — `test_run_py_is_copy`. ## Границы -- Что не должно импортировать что. +- Код новой версии приходит только через API хаба (`GET /agent/release`); общая папка для кода не используется. +- Токены хаба и Eva — только в `agent.json`; в журнал, хаб, логи и тексты ошибок не попадают. +- Темп вызовов Eva задаёт хаб (`wiki_pacing` в `GET /agent/settings`), агент его не хранит. +- Одна команда в полёте: следующая берётся после выполнения и подтверждения отчёта. +- Отчёт до подтверждения: `send_result` повторяет каждые 10 с; 404/409/422 от хаба считаются закрытием. +- Команды только читают Eva, поэтому после перезапуска выполняются заново (design 9). +- Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429. +- Тесты не ходят во внешние сервисы. ## Открытые вопросы -- +- Проверка Eva в `check` и клиент JSON-RPC — задачи 2 и 6 плана. diff --git a/CLAUDE.md b/CLAUDE.md index 5c75d81..8fdc5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,28 +1,54 @@ # eva-agent ## Что это -{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}} +Клиент модуля wiki хаба (sdlc-hub, `../jira-hub`) для виртуалки с доступом к Eva банка. Опрашивает хаб, по командам +`wiki_sync` (выгрузить поддерево страниц с корня) и `wiki_fetch` (скачать вложение) читает Eva и отдаёт данные хабу; +в Eva ничего не пишет. Главный сценарий: хаб ставит `wiki_sync` с кодом корня, агент обходит дерево страниц, шлёт +страницы пачками в стадию хаба и отчитывается списком кодов. +Спека и дизайн: `../jira-hub/openspec/changes/wiki-module/` (hub#91, PR hub#92); решения агента — +`openspec/changes/eva-agent-mvp/design.md`. Issue агента — aep/eva-agent#1. ## Стек и ограничения -- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}} -- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}} +- Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей. +- Запрещено: синтаксис 3.10+ и сторонние импорты. `tests/test_py39.py` ловит синтаксис 3.10+ и сторонние импорты; + `X | Y` в аннотациях функций ловит прогон на /usr/bin/python3 3.9.6. +- `agent.json` содержит токены хаба и Eva — не коммитить, не отправлять в хаб, не выносить с виртуалки. + Шаблон без секретов — `src/agent.example.json`. ## Архитектура -- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью. -- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно. -- {{Ключевые интерфейсы/порты между ними, если есть.}} +- `src/agent.py` — весь агент, один файл. Общие части скопированы из jira-agent v14 без изменения смысла (решение 1): + `Http`/`HttpError`/`RedirectAway`/`TooBig`, `FolderTransport` (хаб через общую папку), `HubClient`, `Journal`, + `Updater` (самообновление), `Runner` (claim → пауза → журнал → выполнение → отчёт), `acquire_lock`, CLI + `run|check|selftest|prune`. Своё: `EvaClient` (клиент Eva), `Executor` (`do_wiki_sync`, `do_wiki_fetch`). +- `src/run.py` — запускатель, дословная копия `../jira-agent/src/run.py` (решение 2); `agent.py` не импортирует. +- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, файл `upload_file`); `Runner` знает хаб, Eva и + журнал; журнал не знает об Eva. Имена полей протокола хаба `jira_calls`/`rate_limited` общие для всех агентов: + в `jira_calls` идёт число вызовов Eva. +- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. Подробнее: `ARCHITECTURE.md`. ## Команды -- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор. -- `{{npm run build / ...}}` — сборка. -- `{{npm run dev / ...}}` — локальный запуск. +- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор. +- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые + ресурсы (так проверяем перед коммитом). +- `/usr/bin/python3 src/agent.py check` — доступность хаба (и Eva — после задачи 6 плана), без записи. +- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`). +- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет). +- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3. +- `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление). ## Тесты -- Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. -- {{Где лежит тестовое окружение (vault, БД, песочница).}} +- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`, + `tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py` (появится вместе с клиентом Eva). +- `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`. +- Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`, + `test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.py` (самообновление), + `test_launcher.py` (запускатель + `test_run_py_is_copy`), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py`). +- Golden-фикстур пока нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический. ## Первая подсистема -{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}} +MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева, +`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub +jira-hub agent publish src/agent.py` (из основного checkout), как у jira-agent; токен — «агент wiki». ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a9105 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# eva-agent + +Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц +Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, +один файл `src/agent.py` и запускатель `src/run.py`. + +Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`). + +Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить). +Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`. diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 56a7081..6400602 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -115,3 +115,27 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге 13. **При расхождении плана с кодом хаба по протоколу прав код хаба.** Поручение владельца; расхождения фиксируются находками `O-n`, хаб не правится. Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e. +14. **Каркас `EvaClient` создан в задаче 1; `Runner(cfg, hub, eva, journal)`.** В `EvaClient` — только Bearer-заголовок, + TLS-настройки, счётчики `calls`/`rate_limited` и время последнего вызова (`last_call_at`, `last_call_iso`). + Обоснование: `Runner` и `Executor` из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без + каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3. + Цена ошибки: перенос полей в другой класс. +15. **`claim` шлёт `last_call_at`, а не `last_jira_call_at`.** + Обоснование: хаб (`core/api.py`) читает `body.get("last_call_at", body.get("last_jira_call_at"))`; новое имя не + привязано к Jira. Поля отчёта `jira_calls`/`rate_limited` остаются как есть: хаб читает только их. + Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус). +16. **Jira-специфичные тесты jira-agent не переносились; `test_clients.py` написан заново на `MiniServer`.** + Исключены: `JiraClient` (multipart, 429/503), `Executor`, `refresh`, история, вложения Jira. Сценарии `Http` + (перенаправления, обрывы, не-JSON, предел `fetch_bytes`) сохранены на маленьком локальном сервере, фейка Eva в + задаче 1 нет (появляется в задаче 2). + Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет. + Цена ошибки: пропущенный сценарий `Http`; виден по сравнению со списком тестов jira-agent. +17. **Тесты цикла работают на заглушке `wiki_fetch` (`failed` «не реализовано»); паузу и время вызова Eva тесты + задают вручную (`eva.last_call_at`, `eva._touch()`).** Результат `done` проверяется через подмену + `Executor.run`/`do_wiki_fetch`. + Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают. +18. **`test_run_py_is_copy` ищет `jira-agent/src/run.py` на 1–3 уровня выше корня репозитория; нет файла — skip с + причиной.** Путь из плана (`../../jira-agent`) верен для основного checkout, из worktree `.wt/<срез>` — на уровень + глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов. +19. **`.gitignore` дополнен `agent.py.new`, `agent.py.prev`, `__pycache__/`, `*.pyc`** (рабочие файлы самообновления + и кэш). Цена ошибки: нет. diff --git a/src/agent.example.json b/src/agent.example.json new file mode 100644 index 0000000..1bd733b --- /dev/null +++ b/src/agent.example.json @@ -0,0 +1,13 @@ +{ + "hub_url": "http://192.168.0.106:8790", + "hub_token": "токен агента wiki из `jira-hub tokens`", + "eva": { + "base_url": "https://eva.example.ru", + "token": "токен Eva (Bearer)" + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "poll_seconds": 5, + "auto_update": true +} diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..909ffc9 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,1084 @@ +#!/usr/bin/env python3 +"""eva-agent — клиент модуля wiki хаба (sdlc-hub) для виртуалки с доступом к Eva банка. + +Опрашивает хаб, выполняет команды wiki_sync и wiki_fetch (только чтение Eva), ведёт журнал на диске. Только +стандартная библиотека, Python 3.9. Общие части (HTTP, папка, журнал, цикл, самообновление, запускатель) — копия +jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/. +Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. + + python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи + python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 + python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней + +Самообновление — через запускатель run.py (спека agent, «Самообновление агента»). +""" +import argparse +import base64 +import fcntl +import hashlib +import http.client +import json +import os +import re +import signal +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple + +AGENT_VERSION = 1 +EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) +EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def log(msg: str) -> None: + print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def load_config(path: str) -> dict: + try: + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + except FileNotFoundError: + sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path) + for key in ("hub_url", "hub_token", "eva"): + if not cfg.get(key): + sys.exit("в %s нет обязательного поля %s" % (path, key)) + if not isinstance(cfg["eva"], dict): + sys.exit("в %s поле eva должно быть объектом {base_url, token}" % path) + for key in ("base_url", "token"): + if not cfg["eva"].get(key): + sys.exit("в %s нет eva.%s" % (path, key)) + if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]): + sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например " + "folder:/Volumes/My Shared Files/RSHB/jira-bridge") + cfg.setdefault("timeout", 60) + cfg.setdefault("poll_seconds", 5) + cfg.setdefault("verify_ssl", True) + return cfg + + +# --------------------------------------------------------------------------- +# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7) +# --------------------------------------------------------------------------- +def read_update_state(path: str) -> Optional[dict]: + """Состояние обновления; нет файла или мусор — None.""" + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except (OSError, ValueError): + log("update.json не читается — считаю, что состояния нет") + return None + return data if isinstance(data, dict) else None + + +def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict: + data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256, + "error": error, "at": now_iso()} + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + return data + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- +class HttpError(Exception): + def __init__(self, code: int, text: str, headers=None): + super().__init__("HTTP %d: %s" % (code, text[:300])) + self.code, self.text, self.headers = code, text, headers or {} + + +class RedirectAway(Exception): + """Сервер перенаправил запрос на другой адрес (схема, хост или порт): учётные данные туда не отправляются.""" + def __init__(self, host: str): + super().__init__("перенаправление на %s" % host) + self.host = host + + +class TooBig(Exception): + """Ответ длиннее предела: чтение оборвано.""" + + +def _origin(url: str) -> Tuple[str, str, Optional[int]]: + u = urllib.parse.urlsplit(url) + scheme = (u.scheme or "").lower() + return scheme, (u.hostname or "").lower(), u.port or {"http": 80, "https": 443}.get(scheme) + + +class _SameOriginRedirect(urllib.request.HTTPRedirectHandler): + """Перенаправления — только в пределах адреса клиента: urllib переносит Authorization и на чужой хост.""" + def __init__(self, base: str): + self.origin = _origin(base) + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _origin(newurl) != self.origin: + fp.close() + raise RedirectAway(urllib.parse.urlsplit(newurl).hostname or newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class Http: + def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None): + self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx + + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.base) + + def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None, + params, timeout) + + def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None, + timeout=None) -> Any: + """Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError.""" + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + req = urllib.request.Request(url, data=data, method=method) + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "application/json") + if content_type is not None: + req.add_header("Content-Type", content_type) + try: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: + raw = resp.read() + status = resp.status + except RedirectAway as e: + self._touch() + raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None + except urllib.error.HTTPError as e: + self._touch() + try: + headers = {k.lower(): v for k, v in e.headers.items()} + text = e.read().decode("utf-8", "replace") + except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: + raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None + finally: + e.close() + raise HttpError(e.code, text, headers) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + self._touch() + try: + return json.loads(raw.decode("utf-8")) if raw else {} + except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError + raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None + + def _opener(self): + """Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на + чужой хост); контекст TLS — из настроек.""" + handlers = [_SameOriginRedirect(self.base)] # type: List[Any] + if self.ctx is not None: + handlers.append(urllib.request.HTTPSHandler(context=self.ctx)) + return urllib.request.build_opener(*handlers) + + def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes: + """GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее + limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send.""" + req = urllib.request.Request(self.base + path, method="GET") + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "*/*") + try: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: + data = resp.read(limit + 1) + rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше + if rest and len(data) <= limit: + raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest)) + except RedirectAway: + self._touch() + raise + except urllib.error.HTTPError as e: + self._touch() + try: + headers = {k.lower(): v for k, v in e.headers.items()} + text = e.read().decode("utf-8", "replace") + except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: + raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None + finally: + e.close() + raise HttpError(e.code, text, headers) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + self._touch() + if len(data) > limit: + raise TooBig("ответ длиннее %d байт" % limit) + return data + + def _touch(self) -> None: + """Вызывается на каждый полученный HTTP-ответ (любой код).""" + + +class EvaClient(Http): + """Клиент Eva: счётчики вызовов и 429, время последнего вызова (для паузы между вызовами). Методы JSON-RPC + добавляются в задачах 2–3; здесь — только каркас, общий для цикла и Executor.""" + + def __init__(self, cfg: dict): + e = cfg["eva"] + ctx = None + if not cfg.get("verify_ssl", True): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif cfg.get("ca_bundle"): + ctx = ssl.create_default_context(cafile=cfg["ca_bundle"]) + super().__init__(e["base_url"], {"Authorization": "Bearer " + e["token"]}, cfg.get("timeout", 60), ctx) + self.calls = 0 + self.rate_limited = 0 + self.last_call_at = None # type: Optional[float] (time.monotonic) + self.last_call_iso = None # type: Optional[str] + self.sleep = time.sleep + + def _touch(self) -> None: + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + + def reset_counters(self) -> None: + self.calls, self.rate_limited = 0, 0 + + +class FolderTransport: + """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). + Запрос — файл req/.json, ответ кладёт реле хаба в resp/.json. Ошибки те же, что у Http.call: + нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport.""" + POLL = 0.1 + BAD_JSON_WAIT = 2.0 + + def __init__(self, root: str, authorization: str): + self.root, self.authorization = root, authorization + + def __repr__(self): + return "" % self.root + + def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any: + if not os.path.isdir(self.root): + raise ConnectionError("нет папки %s" % self.root) + req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp") + rid = uuid.uuid4().hex + try: + os.makedirs(req_dir, exist_ok=True) + os.makedirs(resp_dir, exist_ok=True) + deadline = time.time() + timeout + self._write(req_dir, rid + ".json", { + "id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization, + "deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()}) + except OSError as e: + raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None + resp_path = os.path.join(resp_dir, rid + ".json") + end = time.monotonic() + timeout + while time.monotonic() < end: + if os.path.exists(resp_path): + return self._take(resp_path) + time.sleep(self.POLL) + try: + os.remove(os.path.join(req_dir, rid + ".json")) + except OSError: + pass + raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout)) + + @staticmethod + def _write(directory: str, name: str, payload: dict) -> None: + tmp = os.path.join(directory, "." + name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, os.path.join(directory, name)) + + def _take(self, path: str) -> Any: + end = time.monotonic() + self.BAD_JSON_WAIT + while True: + try: + with open(path, encoding="utf-8") as f: + resp = json.load(f) + if not isinstance(resp, dict): + raise ValueError("ответ не объект") + status, body = resp.get("status"), resp.get("body") + if type(status) is not int or not (body is None or isinstance(body, str)): + raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8) + break + except (ValueError, OSError): + if time.monotonic() >= end: + raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None + time.sleep(self.POLL) + try: + os.remove(path) + except OSError: + pass + text = body or "" # status и body проверены выше (M-5 #18) + if not 200 <= status < 300: + raise HttpError(status, text) + try: + return json.loads(text) if text else {} + except ValueError as e: + raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None + + +class HubClient(Http): + def __init__(self, cfg: dict): + auth = "Bearer " + cfg["hub_token"] + super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60)) + url = cfg["hub_url"] + self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None + + def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: + if self.folder is None: + return super().call(method, path, body, params, timeout) + if params: + path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + return self.folder.call(method, path, body, timeout or self.timeout) + + def claim(self, agent_version, settings_version, last_call_at, update_state=None, launcher_version=None) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_call_at": last_call_at, "update_state": update_state, + "launcher_version": launcher_version}, timeout=max(self.timeout, 45)) + + def release(self) -> dict: + return self.call("GET", "/agent/release") + + def settings(self) -> dict: + return self.call("GET", "/agent/settings") + + def command(self, command_id) -> dict: + return self.call("GET", "/agent/commands/%d" % int(command_id)) + + def result(self, command_id, payload: dict) -> dict: + return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload) + + def upload_file(self, data: bytes) -> dict: + """Файл, скачанный из Eva, — в хранилище хаба: {file (sha256), size}.""" + return self.call("POST", "/agent/files", {"content": base64.b64encode(data).decode("ascii")}) + + def wiki_pages(self, command_id, pages: list) -> dict: + """Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ).""" + return self.call("POST", "/agent/wiki/pages", {"command_id": command_id, "pages": pages}) + + +# --------------------------------------------------------------------------- +# Журнал: только дозапись; started → finished → acked по id команды +# --------------------------------------------------------------------------- +class Journal: + def __init__(self, path: str): + self.path = path + + def _torn_tail(self) -> bool: + """Файл не пуст и не заканчивается переводом строки (оборванная запись).""" + try: + if os.path.getsize(self.path) == 0: + return False + except FileNotFoundError: + return False + with open(self.path, "rb") as f: + f.seek(-1, os.SEEK_END) + return f.read(1) != b"\n" + + def append(self, record: dict) -> None: + record = dict(record) + record.setdefault("at", now_iso()) + data = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8") + if self._torn_tail(): + data = b"\n" + data + fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, "ab") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + + def _scan(self): + """Возвращает (годные записи, номера битых строк). Годная — dict с command_id.""" + good, bad = [], [] + if not os.path.exists(self.path): + return good, bad + with open(self.path, "rb") as f: + for n, raw in enumerate(f, 1): + line = raw.strip() + if not line: + continue + try: + r = json.loads(line.decode("utf-8")) + except ValueError: + r = None + if isinstance(r, dict) and "command_id" in r: + good.append(r) + else: + bad.append(n) + return good, bad + + def records(self) -> List[dict]: + good, bad = self._scan() + for n in bad: + log("журнал: строка %d не читается, пропускаю" % n) + return good + + def last_at(self) -> Optional[datetime]: + """Самое позднее время записи журнала (UTC). Записи без времени или с непонятным временем пропускаются.""" + latest = None + for r in self.records(): + try: + t = datetime.fromisoformat(r["at"]) + except (KeyError, TypeError, ValueError): + continue + if t.tzinfo is None: + continue + if latest is None or t > latest: + latest = t + return latest + + def claimed(self, command_id) -> None: + """Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту.""" + self.append({"kind": "claimed", "command_id": int(command_id)}) + + def started(self, command_id) -> None: + self.append({"kind": "started", "command_id": int(command_id)}) + + def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None: + self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result, + "error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)}) + + def acked(self, command_id) -> None: + self.append({"kind": "acked", "command_id": int(command_id)}) + + def unacked_finished(self) -> List[dict]: + last, acked = {}, set() + for r in self.records(): + if r.get("kind") == "finished": + last[r["command_id"]] = r + acked.discard(r["command_id"]) + elif r.get("kind") == "acked": + acked.add(r["command_id"]) + return [r for cid, r in last.items() if cid not in acked] + + def unfinished_started(self) -> List[int]: + """Порядковый учёт: claimed/started открывают id, finished/acked закрывает; порядок — первое открытие.""" + opened = {} # command_id -> открыта ли сейчас; dict сохраняет порядок первого открытия + for r in self.records(): + kind, cid = r.get("kind"), r["command_id"] + if kind in ("claimed", "started"): + opened[cid] = True + elif kind in ("finished", "acked") and cid in opened: + opened[cid] = False + return [cid for cid, is_open in opened.items() if is_open] + + def prune(self, days: int = 30) -> int: + """Удалить записи id до его последнего acked, если тот старше days дней (возраст — по acked) + и после него у id нет started/finished. Возвращает число удалённых годных записей; битые строки + отбрасываются при перезаписи и учитываются в логе.""" + good, bad = self._scan() + if bad: + log("журнал: удаляю битых строк %d" % len(bad)) + cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 + last_ack, last_open = {}, {} + for i, r in enumerate(good): + cid = r["command_id"] + if r.get("kind") == "acked": + last_ack[cid] = i + elif r.get("kind") in ("claimed", "started", "finished"): + last_open[cid] = i + drop_upto = {} # command_id -> индекс последнего acked: всё до него включительно удаляется + for cid, i in last_ack.items(): + if last_open.get(cid, -1) > i: + continue + try: + ts = _parse_at(good[i].get("at")) + except (ValueError, TypeError): + continue + if ts < cutoff: + drop_upto[cid] = i + keep = [r for i, r in enumerate(good) + if not (r["command_id"] in drop_upto and i <= drop_upto[r["command_id"]])] + tmp = self.path + ".tmp" + try: + os.remove(tmp) + except FileNotFoundError: + pass + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + for r in keep: + f.write((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8")) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, self.path) + return len(good) - len(keep) + + +def _parse_at(value) -> float: + """Метка времени журнала в эпоху; наивное время трактуется как UTC.""" + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + + +# --------------------------------------------------------------------------- +# Исполнитель: команда wiki_sync / wiki_fetch (читает Eva, отдаёт хабу) +# --------------------------------------------------------------------------- +class HubError(Exception): + """Ошибка хаба внутри команды (стадия страниц, загрузка файла): источник — хаб, не Eva (из jira-agent, M-6 #18). + Повтор — только при недоступности хаба и 5xx; команды wiki Eva только читают, повтор безопасен.""" + + def __init__(self, cause: Exception): + super().__init__("хаб: %s" % cause) + self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) + + +class Executor: + def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict): + self.eva, self.hub, self.s = eva, hub, settings + self.sleep = time.sleep + + def run(self, cmd: dict) -> dict: + self.eva.reset_counters() + handler = getattr(self, "do_" + str(cmd.get("type")), None) + if handler is None: + return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) + try: + return handler(cmd.get("params") or {}, cmd) + except HubError as e: + out = self._out("failed", error=str(e)) + out["retryable"] = e.retryable + out["peer"] = "хабом" + return out + except HttpError as e: + out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит + return out + except ConnectionError as e: + out = self._out("failed", error=str(e)) + out["retryable"] = True + return out + + def _out(self, status, result=None, error=None) -> dict: + return {"status": status, "result": result, "error": error, + "jira_calls": self.eva.calls, "rate_limited": self.eva.rate_limited} # имена полей — протокол хаба + + def _hub(self, fn, *args): + """Вызов хаба из команды: его сбой — HubError, чтобы не выдать его за обрыв связи с Eva.""" + try: + return fn(*args) + except (ConnectionError, HttpError) as e: + raise HubError(e) from None + + def do_wiki_sync(self, p: dict, cmd: dict) -> dict: + return self._out("failed", error="wiki_sync: не реализовано") + + def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: + return self._out("failed", error="wiki_fetch: не реализовано") + + +# --------------------------------------------------------------------------- +# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте +# --------------------------------------------------------------------------- +class Updater: + """Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py.""" + RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8) + HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10) + SELFTEST_TIMEOUT = 120 + VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + SHA_RE = re.compile(r"[0-9a-f]{64}") + + def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True, + config_path: Optional[str] = None): + self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled + self.config_path = config_path or os.path.join(base_dir, "agent.json") + self.state_path = os.path.join(base_dir, "update.json") + self.new_path = os.path.join(base_dir, "agent.py.new") + self.agent_path = os.path.join(base_dir, "agent.py") + self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять + self.hints = {} # вид подсказки -> clock() последней записи в лог (не чаще HINT_SECONDS) + self.clock = time.monotonic + self.should_stop = lambda: False + self.run_selftest = self._run_selftest + + def state(self) -> Optional[dict]: + return read_update_state(self.state_path) + + def confirm(self) -> None: + """Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката).""" + st = self.state() + if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION: + try: + write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256")) + except OSError as e: # диск полон — не падать; trial остался, повтор на следующем claim (M-3) + log("не удалось записать update.json: %s" % e) + return + log("обновление до v%d подтверждено" % AGENT_VERSION) + + def _hint(self, kind: str, msg: str) -> None: + now = self.clock() + at = self.hints.get(kind) + if at is None or now - at >= self.HINT_SECONDS: + self.hints[kind] = now + log(msg) + + def check(self, release) -> bool: + """Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75.""" + if not self.enabled or not isinstance(release, dict): + return False + version, sha = release.get("version"), release.get("sha256") + if type(version) is not int or not isinstance(sha, str) or not self.SHA_RE.fullmatch(sha): + self._hint("format", "хаб прислал неполную публикацию: version=%.80r, sha256=%.80r" % (version, sha)) # design 10 + return False + if version <= AGENT_VERSION: + return False + st = self.state() + if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \ + and st.get("sha256") == sha: + return False + if self.launcher_version is None: + self._hint("launcher", "доступна v%d; для самообновления запускайте через run.py" % version) + return False + until = self.retry_after.get((version, sha)) + if until is not None and self.clock() < until: + return False + return self.prepare(version, sha) + + def prepare(self, version: int, sha: str) -> bool: + log("доступна v%d — готовлю обновление" % version) + try: + rel = self.hub.release() + except (ConnectionError, HttpError) as e: + return self._transient(version, sha, "скачивание: %s" % e) + content = rel.get("content") if isinstance(rel, dict) else None + if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha: + return self._transient(version, sha, "публикация сменилась между запросами") + data = content.encode("utf-8") + if hashlib.sha256(data).hexdigest() != sha: + return self._reject(version, sha, "sha256 не сходится") + try: + compile(content, "agent.py.new", "exec") + except (SyntaxError, ValueError) as e: + return self._reject(version, sha, "не компилируется: %s" % e) + if self.VERSION_RE.findall(content) != [str(version)]: + return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version) + tmp = self.new_path + ".tmp" + try: # диск полон, нет прав — временный сбой, не трейсбек (design 9) + with open(tmp, "wb") as f: + f.write(data) + try: + os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777) + except OSError: + pass + os.replace(tmp, self.new_path) + except OSError as e: + return self._transient(version, sha, "файлы: %s" % e) + if self.should_stop(): + self._drop_new() + return False + try: + code, out = self.run_selftest(self.new_path) + except OSError as e: + return self._transient(version, sha, "selftest: %s" % e) + if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии + self._drop_new() + return False + if code == 0: + try: + write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha) + except OSError as e: + return self._transient(version, sha, "update.json: %s" % e) + log("v%d готова, перезапуск через run.py" % version) + return True + if code is None: + return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT) + if code == 3: + return self._transient(version, sha, "selftest: хаб или Eva недоступны") + return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:])) + + def _run_selftest(self, path: str): + try: + p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT) + except subprocess.TimeoutExpired: + return None, "" + return p.returncode, p.stdout.decode("utf-8", "replace") + + def _drop_new(self) -> None: + for path in (self.new_path, self.new_path + ".tmp"): + try: + os.remove(path) + except OSError: + pass + + def _transient(self, version, sha, reason) -> bool: + self._drop_new() + self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS + log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason)) + return False + + def _reject(self, version, sha, reason) -> bool: + self._drop_new() + try: + write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason) + except OSError as e: + log("не удалось записать update.json: %s" % e) + log("v%d отвергнута: %s" % (version, reason)) + return False + + +class Runner: + RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Eva / 5xx (R-10) + RETRY_MIN_WAIT = 30 # секунд до повтора + + def __init__(self, cfg: dict, hub: HubClient, eva: EvaClient, journal: Journal): + self.cfg, self.hub, self.eva, self.journal = cfg, hub, eva, journal + self.settings = None # type: Optional[dict] + self.executor = None # type: Optional[Executor] + self.stop = False + self.sleep = time.sleep + self.idle_limit = None # type: Optional[int] + self.updater = None # type: Optional[Updater] + self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии + + @property + def poll(self) -> float: + return max(float(self.cfg.get("poll_seconds", 5) or 0), 0) + + def hub_wait(self, e: Exception, base: float) -> float: + """Залогировать сбой хаба и вернуть, сколько ждать. 401/403 — токен неверен: повторять часто бессмысленно.""" + if isinstance(e, HttpError) and e.code in (401, 403): + log("хаб: HTTP %d — неверный hub_token в agent.json, повтор через 60 с" % e.code) + return 60 + log("хаб: %s, повтор через %d с" % (e, base)) + return base + + def load_settings(self) -> None: + self.settings = self.hub.settings() + self.executor = Executor(self.eva, self.hub, self.settings) + self.executor.sleep = self.sleep + log("настройки хаба v%s" % self.settings.get("settings_version")) + + def restore_pace(self) -> None: + """После перезапуска время последнего вызова Eva неизвестно (живёт в памяти). Берём самое позднее время записи + журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9). + Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе: + last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Eva (M-7 #12).""" + if self.eva.last_call_at is not None: + return + at = self.journal.last_at() + if at is None: + return + ago = (datetime.now(timezone.utc) - at).total_seconds() + if ago < 0: + log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat()) + self.eva.last_call_at = time.monotonic() + return # вторая строка «не позже <будущее>» противоречила бы первой (M-2 #18) + self.eva.last_call_at = time.monotonic() - ago + log("темп: последний вызов Eva не позже %s (по журналу)" + % at.astimezone(timezone.utc).replace(microsecond=0).isoformat()) + + def send_result(self, rec: dict) -> bool: + """Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена + или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка.""" + cid = rec["command_id"] + payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"), + "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0), + "agent_version": AGENT_VERSION} + while True: + try: + self.hub.result(cid, payload) + self.journal.acked(cid) + return True + except HttpError as e: + if e.code in (404, 409, 422): + log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e)) + self.journal.acked(cid) + return True + wait = self.hub_wait(e, 10) + except ConnectionError as e: + wait = self.hub_wait(e, 10) + if self.stop: + log("остановка: отчёт по #%s не подтверждён, будет дослан при следующем старте" % cid) + return False + self.sleep(wait) + + def wait_seconds(self, seconds) -> bool: + """Спать заданное время шагами по секунде; False — остановлены во время ожидания.""" + remaining = float(seconds or 0) + if remaining > 0: + log(" пауза %.0f с" % remaining) + while remaining > 0 and not self.stop: + step = min(remaining, 1.0) + self.sleep(step) + remaining -= step + return not self.stop + + def wait_pause(self, pause_seconds) -> bool: + """Досыпать до зазора от последнего вызова Eva; False — остановлены во время паузы. + Если вызовов Eva ещё не было — пауза не нужна.""" + pause = float(pause_seconds or 0) + if pause > 0 and self.eva.last_call_at is not None: + return self.wait_seconds(pause - (time.monotonic() - self.eva.last_call_at)) + return not self.stop + + def run_once(self, cmd: dict) -> dict: + try: + return self.executor.run(cmd) + except Exception as e: # баг в обработчике не должен ронять цикл и зацикливать восстановление + log(" ! необработанная ошибка: %r" % e) + return {"status": "failed", "result": None, + "error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]), + "jira_calls": self.eva.calls, "rate_limited": self.eva.rate_limited} + + def execute(self, cmd: dict, journal_claimed: bool = True) -> None: + cid = cmd["id"] + log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) + if journal_claimed: + self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду + if not self.wait_pause(cmd.get("pause_seconds")): + return + self.journal.started(cid) # started — только после паузы + calls = limited = attempt = 0 + while True: + out = self.run_once(cmd) + calls += out["jira_calls"] + limited += out["rate_limited"] + peer = out.pop("peer", "Eva") # с кем оборвалась связь: Eva или хаб + if not out.pop("retryable", False): + break + if attempt >= self.RETRY_ATTEMPTS: + out = {"status": "failed", "result": None, + "error": "результат неизвестен (обрыв связи с %s), повтор безопасен: %s" % (peer, out.get("error"))} + break + attempt += 1 + log(" ! обрыв связи с %s, повтор %d/%d через проверку: %s" % (peer, attempt, self.RETRY_ATTEMPTS, out.get("error"))) + # F-1: обрыв не обновляет last_call_at, поэтому ждём фиксированно от момента отказа, а не от последнего вызова + if not self.wait_seconds(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT)): + return # остановка: finished не пишем, команду подхватит recover + out["jira_calls"], out["rate_limited"] = calls, limited + self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited) + log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) + rec = dict(out) + rec["command_id"] = cid + self.send_result(rec) + + def fetch_command(self, cid): + """Команда из хаба по id; None — хаб такой не знает; при недоступности хаба ждём и повторяем.""" + while True: + try: + return self.hub.command(cid) + except HttpError as e: + if e.code in (404, 409, 422): + log("#%s: хаб ответил %s" % (cid, e)) + return None + wait = self.hub_wait(e, 10) + except ConnectionError as e: + wait = self.hub_wait(e, 10) + if self.stop: + raise InterruptedError(cid) + self.sleep(wait) + + def recover(self) -> None: + for rec in self.journal.unacked_finished(): + log("досылаю отчёт по #%s" % rec["command_id"]) + self.send_result(rec) + for cid in self.journal.unfinished_started(): + if self.stop: + return + try: + cmd = self.fetch_command(cid) + except InterruptedError: + return + if cmd is None: + self.journal.acked(cid) + continue + if cmd.get("status") != "claimed": + log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status"))) + self.journal.acked(cid) + continue + log("#%s выдана до перезапуска — выполняю через проверку" % cid) + self.execute(cmd, journal_claimed=False) # claimed/started для неё уже в журнале (FM-4); пауза — из хаба (M-9) + + def reload_settings_before(self, cmd) -> bool: + """Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться + (если она уже выдана хабом, фиксируем claimed — подхватит recover).""" + while True: + try: + self.load_settings() + return True + except (ConnectionError, HttpError) as e: + wait = self.hub_wait(e, max(self.poll, 1)) + if self.stop: + if cmd: + self.journal.claimed(cmd["id"]) + return False + if not cmd: + self.sleep(wait) + return False + self.sleep(wait) + + def loop(self) -> None: + self.restore_pace() + idle, recovered = 0, False + while not self.stop: + if self.settings is None: + try: + self.load_settings() + except (ConnectionError, HttpError) as e: + self.sleep(self.hub_wait(e, max(self.poll, 1))) + continue + if not recovered: + self.recover() + recovered = not self.stop + continue + try: + upd = self.updater + resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.eva.last_call_iso, + upd.state() if upd else None, upd.launcher_version if upd else None) + except (ConnectionError, HttpError) as e: + self.sleep(self.hub_wait(e, max(self.poll, 1))) + continue + if self.updater: + self.updater.confirm() + cmd = resp.get("command") + if resp.get("settings_version") != self.settings.get("settings_version"): + if not self.reload_settings_before(cmd): + continue + if cmd: + idle = 0 + self.execute(cmd) + continue + if self.updater and not self.stop and self.updater.check(resp.get("agent_release")): + if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py + self.exit_code = EXIT_UPDATE + return + idle += 1 + if self.idle_limit is not None and idle >= self.idle_limit: + return + self.sleep(self.poll) + + +def acquire_lock(journal_path: str): + """Исключительная блокировка <журнал>.lock: один процесс агента на журнал. Хаб довыдаёт команду без отчёта при + следующем claim, поэтому второй процесс выполнял бы ту же команду одновременно с первым. None — уже занято.""" + f = open(journal_path + ".lock", "a") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + f.close() + return None + return f + + +def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: + lock = acquire_lock(journal_path) + if lock is None: + log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) + return EXIT_LOCKED + try: + return _run(cfg, journal_path, config_path) + finally: + lock.close() + + +def cmd_prune(journal_path: str) -> int: + """Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись, + добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3).""" + lock = acquire_lock(journal_path) + if lock is None: + log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path) + return 1 + try: + removed = Journal(journal_path).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 + finally: + lock.close() + + +def launcher_version() -> Optional[int]: + """Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER — имя общее с jira-agent); без запускателя — None.""" + try: + return int(os.environ["JIRA_AGENT_LAUNCHER"]) + except (KeyError, ValueError): + return None + + +def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: + runner = Runner(cfg, HubClient(cfg), EvaClient(cfg), Journal(journal_path)) + runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path) + runner.updater.should_stop = lambda: runner.stop + + def on_signal(signum, frame): + try: + if runner.stop: + log("повторный сигнал — выхожу немедленно") + else: + log("сигнал %s: доделываю текущую команду и выхожу" % signum) + except RuntimeError: # print внутри обработчика, прервавшего другой print (reentrant call) + pass + if runner.stop: + sys.exit(130) + runner.stop = True + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + log("eva-agent v%d: хаб %s, Eva %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["eva"]["base_url"], journal_path)) + runner.loop() + log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен") + return runner.exit_code + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def cmd_check(cfg: dict) -> int: + ok = True + hub = HubClient(cfg) + via = "" + if hub.folder is not None: + hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен + via = " через папку %s" % hub.folder.root + try: + s = hub.settings() + log("хаб: доступен%s, настройки v%s" % (via, s.get("settings_version"))) + except (ConnectionError, HttpError) as e: + log("хаб: НЕДОСТУПЕН — %s" % e) + ok = False + # проверка Eva — в задаче 6 плана + return 0 if ok else 1 + + +def cmd_selftest(cfg: dict) -> int: + """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: + 0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" + log("eva-agent v%d" % AGENT_VERSION) + return 0 if cmd_check(cfg) == 0 else 3 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) + ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"]) + args = ap.parse_args(argv) + cfg = load_config(args.config) + if args.cmd == "check": + return cmd_check(cfg) + if args.cmd == "selftest": + return cmd_selftest(cfg) + if args.cmd == "prune": + return cmd_prune(args.journal) + return cmd_run(cfg, args.journal, args.config) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/run.py b/src/run.py new file mode 100644 index 0000000..b647271 --- /dev/null +++ b/src/run.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления +агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения. +Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет. +Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py». + + python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run + +Обновляется вручную (редко): остановить, заменить файл, запустить. +""" +import fcntl +import hashlib +import json +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import List, Optional + +LAUNCHER_VERSION = 2 +EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged» +EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6) +TRIAL_SECONDS = 600 # новая версия должна записать ok за это время +KILL_GRACE = 60 # после SIGTERM до SIGKILL +BACKOFF_START, BACKOFF_MAX = 5, 300 +HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала +STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков) + + +def log(msg: str) -> None: + print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def read_state(path: str) -> Optional[dict]: + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool: + """Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5).""" + base = base or {} + data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"), + "sha256": base.get("sha256"), "error": error, + "at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + tmp = path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + except OSError as e: + log("не удалось записать update.json: %s" % e) + return False + return True + + +def sha256_file(path: str) -> Optional[str]: + try: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + except OSError: + return None + + +def remove(path: str) -> None: + try: + os.remove(path) + except OSError: + pass + + +def acquire_lock(base_dir: str): + """Один запускатель на каталог. None — уже занято.""" + f = open(os.path.join(base_dir, "run.py.lock"), "a") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + f.close() + return None + return f + + +class Launcher: + def __init__(self, base_dir: str, args: List[str]): + self.dir, self.args = base_dir, list(args) + self.agent = os.path.join(base_dir, "agent.py") + self.new = os.path.join(base_dir, "agent.py.new") + self.prev = os.path.join(base_dir, "agent.py.prev") + self.state_path = os.path.join(base_dir, "update.json") + self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE + self.backoff = BACKOFF_START + self.stopping = False + self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии + self.child = None + + # --- сигналы --------------------------------------------------------------- + def on_sigint(self, signum, frame): + """Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает, + что человек остановил (I-1). Без агента (пауза) — выход.""" + self.interrupted = True + if self.child is None: + self.stopping = True + + def on_sigterm(self, signum, frame): + self.stopping = True + child = self.child + if child is not None and hasattr(child, "send_signal"): + child.send_signal(signal.SIGTERM) + + # --- файлы версии ---------------------------------------------------------- + def reconcile(self) -> None: + """Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката.""" + for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"): + remove(tmp) + if not os.path.exists(self.agent) and os.path.exists(self.prev): + os.replace(self.prev, self.agent) + log("agent.py не найден — восстановлен из agent.py.prev") + st = read_state(self.state_path) + state, sha = (st or {}).get("state"), (st or {}).get("sha256") + if state == "staged": + if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5) + remove(self.new) + write_state(self.state_path, "trial", st) + log("v%s уже на месте — пробный запуск" % st.get("to_version")) + elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha: + log("найдена подготовленная версия — ставлю") + self.apply_update() + else: + remove(self.new) + write_state(self.state_path, "rejected", st, "подготовленный файл потерян") + log("подготовленная v%s потеряна — отвергнута" % st.get("to_version")) + return + remove(self.new) + if state == "trial" and (not sha or sha256_file(self.agent) != sha): + self.drop_stale_trial(st) + + def in_trial(self) -> bool: + """Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2).""" + st = read_state(self.state_path) + if not (st and st.get("state") == "trial"): + return False + if not st.get("sha256") or sha256_file(self.agent) != st.get("sha256"): + self.drop_stale_trial(st) + return False + return True + + def drop_stale_trial(self, st: dict) -> None: + """agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3).""" + reason = "пробный период прерван: agent.py не v%s" % st.get("to_version") + write_state(self.state_path, "rolled_back", st, reason) + log(reason) + + def apply_update(self) -> None: + st = read_state(self.state_path) + if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")): + remove(self.new) + write_state(self.state_path, "rejected", st, "файл изменился после проверки") + log("обновление отвергнуто: файл изменился после проверки") + return + tmp = self.prev + ".tmp" + try: + with open(self.agent, "rb") as src, open(tmp, "wb") as dst: + dst.write(src.read()) + os.replace(tmp, self.prev) + os.replace(self.new, self.agent) # agent.py существует на каждом шаге + except OSError as e: + self.fail_update(st, e) + return + if not write_state(self.state_path, "trial", st): + try: + os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода + except OSError as e: + log("не удалось вернуть agent.py.prev: %s" % e) + self.fail_update(st, "update.json не записан") + return + log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version"))) + + def fail_update(self, st: dict, err) -> None: + """Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4).""" + remove(self.prev + ".tmp") + remove(self.new) + write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err) + log("подмена v%s не удалась: %s" % (st.get("to_version"), err)) + + def rollback(self, st: Optional[dict], reason: str) -> bool: + if not os.path.exists(self.prev): + log("откат невозможен: нет agent.py.prev") + return False + os.replace(self.prev, self.agent) + write_state(self.state_path, "rolled_back", st, reason) + log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason)) + return True + + # --- процесс агента -------------------------------------------------------- + def spawn(self): + env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION)) + return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir) + + def terminate(self, child) -> None: + child.send_signal(signal.SIGTERM) + try: + child.wait(self.kill_grace) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + + def wait_child(self, child, trial: bool): + """(код выхода, «пробный период истёк», «версия подтверждена»). В пробный период ждём ok не дольше trial_seconds. + Подтверждение — любое состояние update.json, кроме trial этой же версии, пока агент жив: запускатель сам + update.json при живом агенте не пишет, значит это сделал подтвердивший агент (ok, затем rejected/staged для + следующей версии — I-1).""" + start = time.monotonic() + st0 = read_state(self.state_path) if trial else None + trial_sha = (st0 or {}).get("sha256") + confirmed = False + while True: + try: + return child.wait(1), False, confirmed + except subprocess.TimeoutExpired: + pass + if trial and not confirmed: + st = read_state(self.state_path) + if st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha): + confirmed = True + elif time.monotonic() - start >= self.trial_seconds: + self.terminate(child) + return child.returncode, True, False + + def pause(self, seconds: float) -> bool: + """Пауза перед перезапуском; False — остановлены во время паузы.""" + end = time.monotonic() + seconds + while not self.stopping: + left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1) + if left <= 0: + break + time.sleep(min(1.0, left)) + return not self.stopping + + def next_backoff(self, uptime: float) -> int: + if uptime >= HEALTHY_UPTIME: + self.backoff = BACKOFF_START + wait = self.backoff + self.backoff = min(self.backoff * 2, BACKOFF_MAX) + return wait + + def run(self) -> int: + self.reconcile() + code = 0 + while True: + if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2) + return code + trial = self.in_trial() + trial_sha = (read_state(self.state_path) or {}).get("sha256") if trial else None + started = time.monotonic() + self.interrupted = False # Ctrl-C относится к этому запуску агента + self.child = self.spawn() + if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл + self.child.send_signal(signal.SIGTERM) + code, timed_out, confirmed = self.wait_child(self.child, trial) + self.child = None + uptime = time.monotonic() - started + st = read_state(self.state_path) + ok = bool(st and st.get("state") == "ok") + if trial and not (ok or confirmed) and st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha) \ + and sha256_file(self.agent) == trial_sha: + confirmed = True # ok и следующая запись агента случились между опросами (I-1) + ok = ok or confirmed + reason = "агент упал (код %s)" % code + if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3) + log("подтверждение пришло в последний момент — отката нет") + self.backoff = BACKOFF_START + continue + if timed_out: + if self.rollback(st, "не вышла на связь за 10 мин"): + continue + elif self.stopping or (self.interrupted and code in STOP_CODES): + return code + elif code == EXIT_UPDATE: + self.apply_update() + self.backoff = BACKOFF_START + if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте + return 0 + continue + elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6) + reason = "журнал занят другим процессом (агент уже запущен)" + elif trial and not ok: + if self.rollback(st, "код %s через %d с" % (code, uptime)): + continue + if self.stopping: + return code + wait = self.next_backoff(uptime) + log("%s, перезапуск через %d с" % (reason, wait)) + if not self.pause(wait): + return code + + +def main(argv=None) -> int: + base = os.path.dirname(os.path.abspath(__file__)) + lock = acquire_lock(base) + if lock is None: + log("запускатель уже работает в %s — второй не стартует" % base) + return 1 + try: + launcher = Launcher(base, sys.argv[1:] if argv is None else argv) + signal.signal(signal.SIGINT, launcher.on_sigint) + signal.signal(signal.SIGTERM, launcher.on_sigterm) + log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent)) + return launcher.run() + finally: + lock.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b10eec1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# Тесты импортируют соседние модули (_common, fake_hub) по простому имени — добавляем каталог в путь. +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) diff --git a/tests/_common.py b/tests/_common.py new file mode 100644 index 0000000..89ed982 --- /dev/null +++ b/tests/_common.py @@ -0,0 +1,29 @@ +"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейки.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +import agent # noqa: E402 + +SETTINGS = { + "wiki_pacing": 0, + "min_agent_version": 1, + "settings_version": 1, +} + +EVA_URL = "http://127.0.0.1:1" # заглушка Executor в Eva не ходит; фейк Eva появится вместе с её клиентом + + +def eva_cfg(eva_url=EVA_URL, **over): + cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token", + "eva": {"base_url": eva_url, "token": "eva-secret"}, + "verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0} + cfg.update(over) + return cfg + + +def no_sleep(_seconds): + pass diff --git a/tests/fake_hub.py b/tests/fake_hub.py new file mode 100644 index 0000000..eabe8f8 --- /dev/null +++ b/tests/fake_hub.py @@ -0,0 +1,158 @@ +"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" +import base64 +import hashlib +import json +import re +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FakeHub: + token = "agent-token" + + def __init__(self, settings): + self.settings = dict(settings) + self.queue = [] + self.commands = {} + self.results = {} + self.claims = 0 + self.claim_bodies = [] + self.fail_results = 0 + self.reject_results = {} + self.in_flight = None + self.release = None # {"version", "sha256", "content"} — опубликованная версия агента + self.release_requests = 0 + self.files = {} # sha256 → содержимое (хранилище файлов хаба) + self.file_requests = 0 + self.pages = [] # тела POST /agent/wiki/pages + self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой + self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб + self.uploaded = [] # содержимое, присланное агентом POST /agent/files + self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа + self.lock = threading.Lock() + hub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + hub.handle(self, "GET") + + def do_POST(self): + hub.handle(self, "POST") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + self.server.server_close() + + def enqueue(self, ctype, params, pause=0): + cid = len(self.commands) + 1 + self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"} + self.queue.append(cid) + return cid + + def put_file(self, data): + sha = hashlib.sha256(data).hexdigest() + self.files[sha] = data + return sha + + def _issued(self, c): + return dict(c["params"]) + + def _release_ref(self): + r = self.release + return {"version": r["version"], "sha256": r["sha256"]} if r else None + + def handle(self, h, method): + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + if h.headers.get("Authorization") != "Bearer " + self.token: + return self._send(h, 401, {"error": "unauthorized"}) + path = h.path.split("?")[0] + if path == "/agent/files" and method == "POST" and self.reject_files == "drop": + h.close_connection = True + h.connection.shutdown(socket.SHUT_RDWR) + return + if path == "/agent/claim" and self.claim_wait: + end = time.monotonic() + self.claim_wait + while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят + with self.lock: + if self.queue: + break + time.sleep(0.05) + with self.lock: + code, payload = self.route(method, path, body) + self._send(h, code, payload) + + def _send(self, h, code, payload): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + h.send_response(code) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, body): + if path == "/agent/claim": + self.claim_bodies.append(body or {}) + self.claims += 1 + if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": + return 200, {"command": None, "settings_version": self.settings["settings_version"], + "agent_release": self._release_ref()} + if not self.queue: + return 200, {"command": None, "settings_version": self.settings["settings_version"], + "agent_release": self._release_ref()} + cid = self.queue.pop(0) + self.commands[cid]["status"] = "claimed" + self.in_flight = cid + c = self.commands[cid] + return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]}, + "settings_version": self.settings["settings_version"], "agent_release": self._release_ref()} + if path == "/agent/release": + self.release_requests += 1 + return (200, self.release) if self.release else (404, {"error": "no_release"}) + if path == "/agent/settings": + return 200, self.settings + if path == "/agent/wiki/pages" and method == "POST": + if self.reject_pages: + return self.reject_pages + self.pages.append(body) + return 200, {"accepted": len(body.get("pages") or [])} + if path == "/agent/files" and method == "POST": + if self.reject_files: + return self.reject_files + data = base64.b64decode(body.get("content") or "", validate=True) + self.uploaded.append(data) + return 200, {"file": self.put_file(data), "size": len(data)} + m = re.match(r"^/agent/commands/(\d+)$", path) + if m: + c = self.commands.get(int(m.group(1))) + return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"}) + m = re.match(r"^/agent/commands/(\d+)/result$", path) + if m: + cid = int(m.group(1)) + if self.fail_results > 0: + self.fail_results -= 1 + return 503, {"error": "busy"} + if cid in self.reject_results: + return self.reject_results[cid], {"error": "rejected"} + if cid not in self.commands: + return 404, {"error": "not_found"} + if self.commands[cid]["status"] in ("done", "failed", "cancelled"): + return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб) + self.results[cid] = body + self.commands[cid]["status"] = body.get("status") + if self.in_flight == cid: + self.in_flight = None + return 200, self.commands[cid] + return 404, {"error": "not_found", "detail": path} diff --git a/tests/fake_relay.py b/tests/fake_relay.py new file mode 100644 index 0000000..2b1255a --- /dev/null +++ b/tests/fake_relay.py @@ -0,0 +1,74 @@ +"""Фейковое реле хаба для тестов агента: забирает req/.json из папки и отвечает resp/.json. +Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать; +garbage — положить нечитаемый ответ; late — ответить через delay секунд.""" +import json +import os +import threading +import time +import urllib.error +import urllib.request + + +def write_atomic(directory, name, payload): + tmp = os.path.join(directory, "." + name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)) + os.replace(tmp, os.path.join(directory, name)) + + +class FakeRelay: + def __init__(self, root, target_base_url="", mode="forward", delay=0.0): + self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay + self.requests = [] + self._stop = threading.Event() + self._thread = threading.Thread(target=self._loop, daemon=True) + for sub in ("req", "resp"): + os.makedirs(os.path.join(root, sub), exist_ok=True) + + def start(self): + self._thread.start() + return self + + def stop(self): + self._stop.set() + self._thread.join(5) + + def _loop(self): + req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp") + while not self._stop.is_set(): + for name in sorted(os.listdir(req_dir)): + if name.startswith(".") or not name.endswith(".json"): + continue + path = os.path.join(req_dir, name) + try: + with open(path, encoding="utf-8") as f: + req = json.load(f) + os.remove(path) + except (OSError, ValueError): + continue + self.requests.append(req) + if self.mode == "silent": + continue + if self.mode == "garbage": + write_atomic(resp_dir, name, "{не json") + continue + if self.mode == "late": + time.sleep(self.delay) + status, body = self._forward(req) + write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body}) + time.sleep(0.02) + + def _forward(self, req): + data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None + r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"]) + r.add_header("Authorization", req["authorization"]) + if data is not None: + r.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(r, timeout=10) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + try: + return e.code, e.read().decode("utf-8") + finally: + e.close() diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..79011ec --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,254 @@ +"""HTTP-слой (Http, EvaClient, HubClient) и конфиг. Перенос из jira-agent: сценарии Http без Jira-специфики.""" +import contextlib +import io +import json +import os +import socket +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +from _common import agent, eva_cfg + + +class MiniServer: + """Крошечный сервер: routes — путь → (код, заголовки, тело-байты); requests — что пришло.""" + + def __init__(self): + self.routes = {} + self.requests = [] + self.last_headers = {} + srv = self + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + srv.requests.append(self.path) + srv.last_headers = {k.lower(): v for k, v in self.headers.items()} + code, headers, body = srv.routes.get(self.path, (404, {}, b"{}")) + self.send_response(code) + for k, v in headers.items(): + self.send_header(k, v) + if "Content-Length" not in headers: + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + self.server.server_close() + + +class ClientsTest(unittest.TestCase): + def setUp(self): + self.srv = MiniServer() + self.eva = agent.EvaClient(eva_cfg(self.srv.base_url)) + + def tearDown(self): + self.srv.stop() + + def test_get_and_counters(self): + self.srv.routes["/x"] = (200, {"Content-Type": "application/json"}, b'{"a": 1}') + self.assertEqual(self.eva.call("GET", "/x"), {"a": 1}) + self.assertEqual(self.eva.calls, 1) + self.assertIsNotNone(self.eva.last_call_at) + self.assertIsNotNone(self.eva.last_call_iso) + self.eva.reset_counters() + self.assertEqual((self.eva.calls, self.eva.rate_limited), (0, 0)) + + def test_bearer_header_and_no_secret_in_repr(self): + self.assertEqual(self.eva.headers["Authorization"], "Bearer eva-secret") + self.assertNotIn("eva-secret", repr(self.eva)) + self.srv.routes["/x"] = (200, {}, b"{}") + self.eva.call("GET", "/x") + self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret") + + def test_secret_not_in_log_output(self): + buf = io.StringIO() + self.srv.routes["/x"] = (200, {}, b"{}") + with contextlib.redirect_stdout(buf): + self.eva.call("GET", "/x") + agent.log("проверка") + self.assertNotIn("eva-secret", buf.getvalue()) + + def test_http_error_carries_code_text_headers(self): + self.srv.routes["/x"] = (429, {"Retry-After": "7"}, b"slow down") + with self.assertRaises(agent.HttpError) as cm: + self.eva.call("GET", "/x") + self.assertEqual(cm.exception.code, 429) + self.assertEqual(cm.exception.headers.get("retry-after"), "7") + self.assertIn("slow down", cm.exception.text) + self.assertEqual(self.eva.calls, 1) + + def test_fetch_bytes(self): + self.srv.routes["/f"] = (200, {}, b"\x89PNG\x00\x01") + self.assertEqual(self.eva.fetch_bytes("/f", 100), b"\x89PNG\x00\x01") + self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret") + self.assertEqual(self.eva.calls, 1) + + def test_fetch_bytes_limit(self): + self.srv.routes["/f"] = (200, {}, b"x" * 20) + with self.assertRaises(agent.TooBig): + self.eva.fetch_bytes("/f", 10) + self.assertEqual(self.eva.fetch_bytes("/f", 20), b"x" * 20) + + def test_fetch_bytes_redirect_same_host(self): + self.srv.routes["/f"] = (302, {"Location": self.srv.base_url + "/moved"}, b"") + self.srv.routes["/moved"] = (200, {}, b"abc") + self.assertEqual(self.eva.fetch_bytes("/f", 100), b"abc") + + def test_fetch_bytes_redirect_other_host(self): + port = self.srv.server.server_port + self.srv.routes["/f"] = (302, {"Location": "http://localhost:%d/moved" % port}, b"") + self.srv.routes["/moved"] = (200, {}, b"abc") + with self.assertRaises(agent.RedirectAway) as e: + self.eva.fetch_bytes("/f", 100) + self.assertEqual(e.exception.host, "localhost") + self.assertNotIn("/moved", self.srv.requests) + + def test_fetch_bytes_cut_body_is_connection_error(self): + self.srv.routes["/f"] = (200, {"Content-Length": "40"}, b"x" * 10) # обещано 40, отдано 10 + with self.assertRaises(ConnectionError): + self.eva.fetch_bytes("/f", 1000) + + def test_json_call_redirect_other_host_refused(self): + port = self.srv.server.server_port + self.srv.routes["/a"] = (302, {"Location": "http://localhost:%d/b" % port}, b"") + self.srv.routes["/b"] = (200, {}, b"{}") + with self.assertRaises(agent.HttpError) as e: + self.eva.call("GET", "/a") + self.assertIn("localhost", e.exception.text) + self.assertNotIn("/b", self.srv.requests) + + def test_json_call_redirect_same_host_followed(self): + self.srv.routes["/a"] = (302, {"Location": self.srv.base_url + "/b"}, b"") + self.srv.routes["/b"] = (200, {}, b'{"ok": true}') + self.assertEqual(self.eva.call("GET", "/a"), {"ok": True}) + + def test_error_body_read_failure_is_connection_error(self): + import urllib.error + + class Broken(io.BytesIO): + def read(self, *a): + raise TimeoutError("timed out") + + err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) + with mock.patch("urllib.request.OpenerDirector.open", side_effect=err): + with self.assertRaises(ConnectionError): + self.eva.call("GET", "/x") + self.assertEqual(self.eva.calls, 1) + + def test_incomplete_read_is_connection_error(self): + import http.client + with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)): + with self.assertRaises(ConnectionError): + self.eva.call("GET", "/x") + + def test_incomplete_read_inside_response_body(self): + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + + def serve(): + c, _ = srv.accept() + c.recv(4096) + c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":") + c.close() + + t = threading.Thread(target=serve, daemon=True) + t.start() + client = agent.EvaClient(eva_cfg("http://127.0.0.1:%d" % srv.getsockname()[1])) + try: + with self.assertRaises(ConnectionError): + client.call("GET", "/x") + finally: + t.join(2) + srv.close() + + def test_unreachable_raises_connection_error(self): + with self.assertRaises(ConnectionError): + agent.EvaClient(eva_cfg("http://127.0.0.1:1")).call("GET", "/x") + + def test_non_json_200_becomes_http_error(self): + self.srv.routes["/x"] = (200, {}, b"\xff\xfe not json") + with self.assertRaises(agent.HttpError) as cm: + self.eva.call("GET", "/x") + self.assertEqual(cm.exception.code, 200) + self.assertEqual(self.eva.calls, 1) + + def test_hub_client_claim_and_wiki_pages_routes(self): + calls = [] + hub = agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t")) + hub.call = lambda method, path, body=None, params=None, timeout=None: (calls.append((method, path, body, timeout)), {})[1] + hub.claim(1, 2, None) + hub.wiki_pages(7, [{"code": "DOC-1"}]) + hub.upload_file(b"abc") + self.assertEqual(calls[0][:2], ("POST", "/agent/claim")) + self.assertEqual(calls[0][2]["last_call_at"], None) + self.assertGreaterEqual(calls[0][3], 45) + self.assertEqual(calls[1], ("POST", "/agent/wiki/pages", {"command_id": 7, "pages": [{"code": "DOC-1"}]}, None)) + self.assertEqual(calls[2][:2], ("POST", "/agent/files")) + self.assertEqual(calls[2][2], {"content": "YWJj"}) + + def test_load_config_requires_fields(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + + def write(cfg): + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f) + + for bad in ({"hub_url": "http://x", "hub_token": "t"}, + {"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e"}}, + {"hub_url": "http://x", "hub_token": "t", "eva": {"token": "z"}}, + {"hub_url": "http://x", "hub_token": "t", "eva": "https://e"}): + write(bad) + with self.assertRaises(SystemExit): + agent.load_config(path) + write({"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e", "token": "z"}}) + cfg = agent.load_config(path) + self.assertEqual((cfg["timeout"], cfg["poll_seconds"], cfg["verify_ssl"]), (60, 5, True)) + self.assertNotIn("auto_update", cfg) # умолчание true применяет _run: `is not False` + with self.assertRaises(SystemExit): + agent.load_config(os.path.join(d, "нет.json")) + + def test_example_config_loads(self): + cfg = agent.load_config(os.path.join(agent.HERE, "agent.example.json")) + self.assertEqual(cfg["auto_update"], True) + self.assertIn("base_url", cfg["eva"]) + + def test_check_command(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(eva_cfg(), f) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.main(["--config", path, "check"]), 1) # хаб недоступен (порт 1) + self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) + + def test_selftest_command(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(eva_cfg(), f) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.main(["--config", path, "selftest"]), 3) # недоступность — 3, а не 1 + self.assertIn("eva-agent v%d" % agent.AGENT_VERSION, out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_folder.py b/tests/test_folder.py new file mode 100644 index 0000000..0e3ff9e --- /dev/null +++ b/tests/test_folder.py @@ -0,0 +1,240 @@ +import base64 +import hashlib +import io +import json +import os +import tempfile +import time +import unittest +from contextlib import redirect_stdout +from datetime import datetime, timezone + +from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_hub import FakeHub +from fake_relay import FakeRelay + + +class FolderTransportTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути + os.makedirs(self.root) + self.hub = FakeHub(SETTINGS) + self.relay = None + + def tearDown(self): + if self.relay: + self.relay.stop() + self.hub.stop() + self.tmp.cleanup() + + def client(self, timeout=5): + cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout} + return agent.HubClient(cfg) + + def run_relay(self, mode="forward", delay=0.0): + self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start() + return self.relay + + def listing(self, sub): + return sorted(os.listdir(os.path.join(self.root, sub))) + + def test_settings_through_folder(self): + self.run_relay() + hub = self.client() + self.assertIsNotNone(hub.folder) + self.assertEqual(hub.settings()["settings_version"], 1) + self.assertEqual(self.listing("resp"), []) + self.assertEqual(self.listing("req"), []) + req = self.relay.requests[0] + self.assertEqual(req["method"], "GET") + self.assertEqual(req["path"], "/agent/settings") + self.assertEqual(req["authorization"], "Bearer " + self.hub.token) + self.assertEqual(len(req["id"]), 32) + + def test_error_status_raises_http_error(self): + self.run_relay() + with self.assertRaises(agent.HttpError) as e: + self.client().result(999, {"status": "done"}) + self.assertEqual(e.exception.code, 404) + + def test_claim_deadline_at_least_45_seconds(self): + self.run_relay() + before = time.time() + self.client(timeout=5).claim(2, 1, None) + deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"]) + self.assertIsNotNone(deadline.tzinfo) + self.assertGreaterEqual(deadline.timestamp() - before, 44.5) + + def test_no_answer_gives_connection_error_and_removes_request(self): + hub = self.client(timeout=0.5) + t0 = time.monotonic() + with self.assertRaises(ConnectionError) as e: + hub.settings() + self.assertLess(time.monotonic() - t0, 2) + self.assertIn("не ответил через папку", str(e.exception)) + self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], []) + + def test_missing_folder_gives_connection_error(self): + cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5} + with self.assertRaises(ConnectionError) as e: + agent.HubClient(cfg).settings() + self.assertIn("нет папки", str(e.exception)) + + def test_bad_json_response_gives_connection_error(self): + self.run_relay("garbage") + hub = self.client() + hub.folder.BAD_JSON_WAIT = 0.3 + with self.assertRaises(ConnectionError) as e: + hub.settings() + self.assertIn("нечитаемый ответ", str(e.exception)) + + def _bad_response(self, payload): + path = os.path.join(self.root, "resp-bad.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f) + ft = agent.FolderTransport(self.root, "Bearer x") + ft.BAD_JSON_WAIT = 0.3 + return ft, path + + def test_wrong_status_type_is_unreadable(self): + for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"}, + {"id": "x", "status": 200, "body": 5}): + ft, path = self._bad_response(payload) + with self.assertRaises(ConnectionError) as e: + ft._take(path) + self.assertIn("нечитаемый ответ", str(e.exception)) + + def test_no_answer_message_has_no_hub_prefix(self): + hub = self.client(timeout=0.3) + with self.assertRaises(ConnectionError) as e: + hub.settings() + self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8) + + def test_late_response_is_not_reused(self): + self.run_relay("late", delay=1.0) + with self.assertRaises(ConnectionError): + self.client(timeout=0.3).settings() + time.sleep(1.3) # опоздавший ответ лёг в resp/ + late = self.listing("resp") + self.assertEqual(len(late), 1) + self.relay.mode = "forward" + self.assertEqual(self.client().settings()["settings_version"], 1) + self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут + + def test_foreign_files_untouched(self): + resp_dir = os.path.join(self.root, "resp") + os.makedirs(resp_dir, exist_ok=True) + foreign = os.path.join(resp_dir, "0" * 32 + ".json") + with open(foreign, "w", encoding="utf-8") as f: + json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f) + self.run_relay() + self.client().settings() + self.assertTrue(os.path.exists(foreign)) + + def test_http_url_keeps_http_transport(self): + cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5} + hub = agent.HubClient(cfg) + self.assertIsNone(hub.folder) + self.assertEqual(hub.settings()["settings_version"], 1) + + def test_wiki_pages_through_folder(self): + self.run_relay() + pages = [{"code": "DOC-1", "name": "Корень", "html": "

" + "я" * 200000 + "

"}] + got = self.client().wiki_pages(5, pages) + self.assertEqual(got, {"accepted": 1}) + self.assertEqual(self.hub.pages, [{"command_id": 5, "pages": pages}]) + self.assertEqual(self.relay.requests[0]["path"], "/agent/wiki/pages") + + def test_wiki_pages_over_http(self): + hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}) + self.assertEqual(hub.wiki_pages(5, [{"code": "DOC-1"}]), {"accepted": 1}) + self.assertEqual(self.hub.pages[0]["command_id"], 5) + + def test_wiki_pages_rejected_is_http_error(self): + self.hub.reject_pages = (422, {"error": "bad_page"}) + hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}) + with self.assertRaises(agent.HttpError) as e: + hub.wiki_pages(5, [{}]) + self.assertEqual(e.exception.code, 422) + + def test_upload_file_through_folder(self): + self.run_relay() + data = os.urandom(300 * 1024) + got = self.client().upload_file(data) + self.assertEqual(got, {"file": hashlib.sha256(data).hexdigest(), "size": len(data)}) + self.assertEqual(self.hub.uploaded, [data]) + self.assertEqual(self.relay.requests[0]["path"], "/agent/files") + + def test_upload_file_over_http(self): + hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}) + self.assertEqual(hub.upload_file(b""), {"file": hashlib.sha256(b"").hexdigest(), "size": 0}) + self.assertEqual(self.hub.uploaded, [b""]) + + + +class FolderIntegrationTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") + os.makedirs(self.root) + self.hub = FakeHub(SETTINGS) + self.relay = FakeRelay(self.root, self.hub.base_url).start() + + def tearDown(self): + self.relay.stop() + self.hub.stop() + self.tmp.cleanup() + + def cfg(self): + return eva_cfg(hub_url="folder:" + self.root, hub_token=self.hub.token) + + def test_version(self): + self.assertEqual(agent.AGENT_VERSION, 1) + + def test_runner_through_folder(self): + cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}) + cfg = self.cfg() + journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl")) + runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal) + runner.sleep = lambda s: None + runner.eva.sleep = no_sleep + runner.idle_limit = 1 + runner.loop() + self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor + self.assertEqual(self.hub.results[cid]["error"], "wiki_fetch: не реализовано") + self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION) + self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()]) + + def test_check_reports_folder(self): + out = io.StringIO() + with redirect_stdout(out): + code = agent.cmd_check(self.cfg()) + self.assertEqual(code, 0) + self.assertIn("хаб: доступен через папку %s, настройки v1" % self.root, out.getvalue()) + + def test_check_folder_silent_relay(self): + self.relay.stop() + self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start() + out = io.StringIO() + t0 = time.monotonic() + with redirect_stdout(out): + code = agent.cmd_check(self.cfg()) + self.assertEqual(code, 1) + self.assertLess(time.monotonic() - t0, 13) + self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) + self.assertIn("не ответил через папку", out.getvalue()) + + def test_relative_folder_url_rejected(self): + path = os.path.join(self.tmp.name, "agent.json") + for url in ("folder:relative/path", "folder:"): + cfg = self.cfg() + cfg["hub_url"] = url + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f) + with self.assertRaises(SystemExit) as e: + agent.load_config(path) + self.assertIn("абсолютный путь", str(e.exception.code)) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..5e63fef --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,154 @@ +import json +import os +from datetime import datetime, timezone +import tempfile +import unittest +from unittest import mock + +from _common import agent + + +class JournalTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "journal.jsonl") + self.j = agent.Journal(self.path) + patcher = mock.patch.object(agent, "log") + self.log = patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + self.dir.cleanup() + + def test_empty(self): + self.assertEqual(self.j.records(), []) + self.assertEqual(self.j.unacked_finished(), []) + self.assertEqual(self.j.unfinished_started(), []) + + def test_lifecycle(self): + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [1]) + self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0) + self.assertEqual(self.j.unfinished_started(), []) + pending = self.j.unacked_finished() + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0]["command_id"], 1) + self.assertEqual(pending[0]["result"], {"key": "A-1"}) + self.assertEqual(pending[0]["jira_calls"], 2) + self.j.acked(1) + self.assertEqual(self.j.unacked_finished(), []) + self.j.started(2) + self.j.started(3) + self.j.finished(3, "failed", None, "HTTP 400", 1, 0) + self.assertEqual(self.j.unfinished_started(), [2]) + self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3]) + + def test_broken_line_is_skipped(self): + self.j.started(1) + with open(self.path, "a", encoding="utf-8") as f: + f.write("{не json\n") + self.j.finished(1, "done", None, None, 1, 0) + self.assertEqual(len(self.j.records()), 2) + self.log.assert_called_with("журнал: строка 2 не читается, пропускаю") + + def test_prune_keeps_unacked_and_recent(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"}) + self.j.started(2) + self.j.finished(2, "done", None, None, 1, 0) + self.j.acked(2) + self.j.started(3) + removed = self.j.prune(days=30) + self.assertEqual(removed, 3) + ids = [(r["kind"], r["command_id"]) for r in self.j.records()] + self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)]) + self.assertEqual(self.j.prune(days=30), 0) + + def test_no_secrets_in_records(self): + self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0) + with open(self.path, encoding="utf-8") as f: + text = f.read() + self.assertNotIn("password", text) + self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished") + + + def test_torn_tail_is_not_glued(self): + with open(self.path, "wb") as f: + f.write('{"kind": "star'.encode("utf-8")) + self.j.started(1) + recs = self.j.records() + self.assertEqual([(r["kind"], r["command_id"]) for r in recs], [("started", 1)]) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_restart_after_acked_reopens(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.acked(1) + self.assertEqual(self.j.unfinished_started(), []) + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_duplicate_started_is_deduplicated_in_first_open_order(self): + self.j.started(2) + self.j.started(1) + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [2, 1]) + self.j.finished(2, "done", None, None, 1, 0) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_prune_keeps_id_with_newer_records(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"}) + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + before = self.j.records() + self.assertEqual(self.j.prune(days=30), 0) + self.assertEqual(self.j.records(), before) + + def test_non_dict_and_no_command_id_lines_are_skipped(self): + with open(self.path, "wb") as f: + f.write(b'[1, 2]\n5\n{"kind": "started"}\n') + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.acked(1) + self.assertEqual(len(self.j.records()), 3) + self.assertEqual(self.j.unacked_finished(), []) + self.assertEqual(self.j.unfinished_started(), []) + self.assertEqual(self.j.prune(days=30), 0) + self.log.assert_any_call("журнал: удаляю битых строк 3") + self.assertEqual(len(self.j.records()), 3) + with open(self.path, encoding="utf-8") as f: + self.assertNotIn("[1, 2]", f.read()) + + def test_naive_and_invalid_at_in_prune(self): + self.j.started(1) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00"}) + self.j.started(2) + self.j.append({"kind": "acked", "command_id": 2, "at": "не дата"}) + self.assertEqual(self.j.prune(days=30), 2) + self.assertEqual([(r["kind"], r["command_id"]) for r in self.j.records()], [("started", 2), ("acked", 2)]) + + def test_journal_file_is_private(self): + self.j.started(1) + mode = os.stat(self.path).st_mode & 0o777 + self.assertEqual(mode & 0o077, 0) + + def test_last_at_is_latest_record_time(self): + self.assertIsNone(self.j.last_at()) + self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"}) + self.j.append({"kind": "finished", "command_id": 1, "at": "2026-10-13T10:05:00+00:00"}) + self.j.append({"kind": "acked", "command_id": 1, "at": "2026-10-13T10:04:00+00:00"}) + self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 5, tzinfo=timezone.utc)) + + def test_last_at_skips_bad_and_missing_times(self): + self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"}) + self.j.append({"kind": "started", "command_id": 1, "at": "не время"}) + self.j.append({"kind": "started", "command_id": 1, "at": "2026-10-13T11:00:00"}) # без зоны — пропуск + with open(self.path, "a", encoding="utf-8") as f: + f.write("битая строка\n") + self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc)) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..9325b42 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,503 @@ +import hashlib +import json +import os +import signal +import subprocess +import sys +import tempfile +import textwrap +import time +import unittest + +from _common import SRC + +if SRC not in sys.path: + sys.path.insert(0, SRC) +import run # noqa: E402 + +# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json). +STATE_HELPERS = textwrap.dedent(''' + import json, os, sys + HERE = os.path.dirname(os.path.abspath(__file__)) + STATE = os.path.join(HERE, "update.json") + def state(): + try: + return json.load(open(STATE, encoding="utf-8")) + except Exception: + return None + def write(**kw): + json.dump(kw, open(STATE, "w", encoding="utf-8")) + def mark(name): + open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n") + def human_stop(code=0): + # Ctrl-C в терминале получает вся группа: запускатель тоже видит SIGINT, затем агент выходит + import signal + os.kill(os.getppid(), signal.SIGINT) + sys.exit(code) +''') + + +def fake(name, body): + return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body) + + +# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0. +V5_UPDATES = fake("v5", ''' + import hashlib + st = state() + if st is None: + data = open(os.path.join(HERE, "next.py"), "rb").read() + open(os.path.join(HERE, "agent.py.new"), "wb").write(data) + write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest()) + sys.exit(75) + human_stop() +''') +# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new. +V5_BAD_STAGE = fake("v5", ''' + st = state() + if st is None: + open(os.path.join(HERE, "agent.py.new"), "w").write("мусор") + write(state="staged", from_version=5, to_version=6, sha256="чужой") + sys.exit(75) + human_stop() +''') +V6_OK_THEN_STOP = fake("v6", ''' + st = state() + write(**dict(st, state="ok")) + human_stop() +''') +V6_CRASH = fake("v6", 'sys.exit(1)\n') +V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n') +V6_SEES_STATE = fake("v6", ''' + mark("seen:" + (state() or {}).get("state", "-")) + write(**dict(state(), state="ok")) + human_stop() +''') +V6_OK_ON_TERM = fake("v6", ''' + import signal, time + if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2: + human_stop() + def term(*a): + write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода + sys.exit(0) + signal.signal(signal.SIGTERM, term) + time.sleep(60) +''') + + +# v6 подтвердился (ok), через delay с записал rejected для v10 с другим sha (как Updater._reject) и упал с кодом 1. +V6_OK_THEN_REJECT_NEXT = fake("v6", ''' + import time + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + if n > 2: + human_stop() + st = state() + write(**dict(st, state="ok")) + time.sleep(DELAY) + write(state="rejected", from_version=6, to_version=10, sha256="чужой", error="отвергнута") + sys.exit(1) +''') + + +class LauncherTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"]) + self.pauses = [] + self.l.pause = lambda s: (self.pauses.append(s), True)[1] + self.old_sigint = signal.signal(signal.SIGINT, self.l.on_sigint) # как в run.main: Ctrl-C видит и запускатель + + def tearDown(self): + signal.signal(signal.SIGINT, self.old_sigint) + self.tmp.cleanup() + + def put(self, name, text): + with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f: + f.write(text) + + def read(self, name): + with open(os.path.join(self.dir, name), encoding="utf-8") as f: + return f.read() + + def state(self): + return json.loads(self.read("update.json")) + + def runs(self): + return self.read("runs.log").split() + + def next_version(self, text): + self.put("next.py", text) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def stage(self, new_text, to_version=6): + self.put("agent.py.new", new_text) + sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest() + self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha})) + return sha + + def sha(self, text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def path(self, name): + return os.path.join(self.dir, name) + + def test_update_applied_and_confirmed(self): + self.put("agent.py", V5_UPDATES) + sha = self.next_version(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) + self.assertEqual(self.read("agent.py.prev"), V5_UPDATES) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + st = self.state() + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha)) + self.assertEqual(self.runs(), ["v5", "v6"]) + + def test_crash_in_trial_rolls_back(self): + self.put("agent.py", V5_UPDATES) + sha = self.next_version(V6_CRASH) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha)) + self.assertRegex(st["error"], r"^код 1 через \d+ с$") + self.assertEqual(self.runs(), ["v5", "v6", "v5"]) + self.assertEqual(self.pauses, []) # откат без паузы + + def test_hang_in_trial_rolls_back(self): + self.l.trial_seconds = 1 + self.l.kill_grace = 5 + self.put("agent.py", V5_UPDATES) + self.next_version(V6_HANG) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин") + + def test_crash_after_ok_restarts_with_backoff(self): + self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"})) + self.put("agent.py", fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 2 else sys.exit(1) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5, 10]) + self.assertEqual(self.state()["state"], "ok") # отката нет + + def test_backoff_caps_and_resets(self): + self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300]) + self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала + + def test_75_without_staged_rejected(self): + self.put("agent.py", V5_BAD_STAGE) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_BAD_STAGE) + self.assertEqual(self.runs(), ["v5", "v5"]) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки")) + + def test_staged_at_start_is_applied(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.stage(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6"]) + self.assertEqual(self.state()["state"], "ok") + + def test_stray_new_removed_at_start(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.put("agent.py.new", "остаток") + self.assertEqual(self.l.run(), 0) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + + def test_missing_agent_restored_from_prev(self): + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v5"]) + + def test_garbage_update_json_is_no_state(self): + self.put("update.json", '{"state": "tri') + self.put("agent.py", fake("v5", "human_stop()\n")) + self.assertEqual(self.l.run(), 0) + + def test_env_and_args_passed(self): + self.put("agent.py", fake("v5", ''' + open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:])) + human_stop(130) + ''')) + self.assertEqual(self.l.run(), 130) + self.assertEqual(self.read("env.txt"), "2 run --journal j.jsonl") + + def test_sigint_without_child_stops(self): + self.l.on_sigint(signal.SIGINT, None) + self.assertTrue(self.l.stopping) + + def test_sigint_with_child_ignored(self): + self.l.child = object() + self.l.on_sigint(signal.SIGINT, None) + self.assertFalse(self.l.stopping) + self.assertTrue(self.l.interrupted) # выход агента с 0 — остановка человеком + + def test_exit_0_without_signal_in_trial_rolls_back(self): + self.put("agent.py", V5_UPDATES) + self.next_version(fake("v6", "sys.exit(0)\n")) # сама вышла, никто не нажимал Ctrl-C (I-1) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual(st["state"], "rolled_back") + self.assertRegex(st["error"], r"^код 0 через \d+ с$") + self.assertEqual(self.runs(), ["v5", "v6", "v5"]) + + def test_exit_0_without_signal_after_ok_restarts(self): + self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"})) + self.put("agent.py", fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 1 else sys.exit(0) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5]) # выход 0 без сигнала — как падение + self.assertEqual(self.runs(), ["v6", "v6"]) + + def test_ctrl_c_with_75_applies_and_stops(self): + self.put("next.py", V6_OK_THEN_STOP) + self.put("agent.py", fake("v5", ''' + import hashlib, signal + data = open(os.path.join(HERE, "next.py"), "rb").read() + open(os.path.join(HERE, "agent.py.new"), "wb").write(data) + write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest()) + os.kill(os.getppid(), signal.SIGINT) # Ctrl-C пришёл, когда версия уже подготовлена + sys.exit(75) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) # подмена сделана, новая не запущена + self.assertEqual(self.state()["state"], "trial") # пробный период начнётся при старте + self.assertEqual(self.runs(), ["v5"]) + + def test_stale_interrupt_does_not_hide_exit_0(self): + self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"})) + self.put("agent.py", fake("v6", ''' + import signal + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + if n == 1: + os.kill(os.getppid(), signal.SIGINT) # Ctrl-C до установки обработчиков агента + sys.exit(1) + human_stop() if n > 2 else sys.exit(0) + ''')) + self.l.stopping = False + orig = self.l.on_sigint + def sigint_child_alive(signum, frame): # агент жив — запускатель только запоминает Ctrl-C + self.l.child = self.l.child or object() + orig(signum, frame) + signal.signal(signal.SIGINT, sigint_child_alive) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v6", "v6"]) # второй выход 0 без сигнала — перезапуск + self.assertEqual(len(self.pauses), 2) + + def test_stop_before_spawn(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.l.stopping = True # SIGTERM пришёл между запусками (M-2) + self.assertEqual(self.l.run(), 0) + self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log"))) + + def test_pause_never_sleeps_negative(self): + clock = iter([0.0, 0.5, 1.5, 1.6]) # остаток перешёл через ноль между вызовами (M-1) + slept = [] + orig_m, orig_s = run.time.monotonic, run.time.sleep + run.time.monotonic, run.time.sleep = (lambda: next(clock)), slept.append + try: + self.assertTrue(run.Launcher.pause(self.l, 1.0)) + finally: + run.time.monotonic, run.time.sleep = orig_m, orig_s + self.assertTrue(all(x >= 0 for x in slept), slept) + + def test_second_launcher_refused(self): + with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f: + self.put("run.py", f.read()) + self.put("agent.py", fake("v5", "sys.exit(0)\n")) + lock = run.acquire_lock(self.dir) + try: + p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=30) + finally: + lock.close() + self.assertEqual(p.returncode, 1) + self.assertIn("запускатель уже работает", p.stdout.decode("utf-8")) + self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log"))) + + def test_staged_already_replaced_resumes_trial(self): + """M-5: запускатель упал между os.replace(new, agent) и записью trial.""" + self.stage(V6_SEES_STATE) + os.replace(self.path("agent.py.new"), self.path("agent.py")) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда + self.assertEqual(self.state()["state"], "ok") + + def test_staged_without_file_rejected(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64})) + self.assertEqual(self.l.run(), 0) + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян")) + self.assertEqual(self.runs(), ["v5"]) + + def test_stale_trial_marked_rolled_back(self): + """M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial.""" + self.l.trial_seconds = 1 + self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода + self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64})) + self.assertEqual(self.l.run(), 0) + st = self.state() + self.assertEqual((st["state"], st["to_version"], st["error"]), + ("rolled_back", 9, "пробный период прерван: agent.py не v9")) + self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период + + def test_trial_without_sha_is_stale(self): + self.put("agent.py", fake("v8", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.state()["state"], "rolled_back") + + def test_trial_survives_launcher_restart(self): + """M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат.""" + self.put("agent.py", V6_CRASH) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v5"]) + self.assertEqual(self.state()["state"], "rolled_back") + + def test_ok_at_last_moment_no_rollback(self): + """M-4: пробный период истёк, но агент записал ok, получив SIGTERM.""" + self.l.trial_seconds = 1 + self.l.kill_grace = 5 + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_ON_TERM) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM) + self.assertEqual(self.state()["state"], "ok") + self.assertEqual(self.runs(), ["v5", "v6", "v6"]) + self.assertEqual(self.pauses, []) # перезапуск без паузы + + def _confirmed_then_rejected_next(self, delay): + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay))) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay))) + self.assertEqual(self.runs(), ["v5", "v6", "v6"]) + st = self.state() + self.assertEqual((st["state"], st["to_version"]), ("rejected", 10)) # не rolled_back: v10 не заблокирована + + def test_confirmed_then_next_rejected_no_rollback(self): + """I-1: агент записал ok, потом сам перезаписал update.json (rejected для v10) и упал — v6 здоровая, откат не нужен.""" + self._confirmed_then_rejected_next(2.5) + + def test_confirmed_and_rejected_within_poll_no_rollback(self): + """I-1: ok и rejected записаны быстрее, чем запускатель опрашивает файл раз в секунду.""" + self._confirmed_then_rejected_next(0) + + def test_locked_in_trial_no_rollback(self): + """M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию.""" + v6 = fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 1 else sys.exit(73) + ''') + self.put("agent.py", v6) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v6"]) + self.assertEqual(self.pauses, [5]) + self.assertEqual(self.read("agent.py"), v6) + self.assertEqual(self.state()["state"], "trial") + + def test_locked_outside_trial_backs_off(self): + self.put("agent.py", fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 3 else sys.exit(73) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5, 10, 20]) + + def test_os_error_before_replace_rejected(self): + """M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог).""" + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_STOP) + os.mkdir(self.path("agent.py.prev.tmp")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual(st["state"], "rejected") + self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"]) + self.assertFalse(os.path.exists(self.path("agent.py.new"))) + self.assertEqual(self.runs(), ["v5", "v5"]) + + def test_trial_not_written_reverts(self): + orig = run.write_state + def flaky(path, state, base, error=None): + return False if state == "trial" else orig(path, state, base, error) + run.write_state = flaky + self.addCleanup(setattr, run, "write_state", orig) + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан")) + self.assertEqual(self.runs(), ["v5", "v5"]) + + def test_write_state_error_does_not_raise(self): + self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {})) + + def test_sha256_file_unreadable_is_none(self): + self.assertIsNone(run.sha256_file(self.path("нет-файла"))) + + def test_tmp_files_removed_at_start(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"): + self.put(name, "остаток") + self.assertEqual(self.l.run(), 0) + for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"): + self.assertFalse(os.path.exists(self.path(name)), name) + + def test_sigterm_forwarded_and_no_restart(self): + """M-10 #22: on_sigterm — пересылка агенту, нового запуска нет.""" + import threading + self.put("agent.py", fake("v5", ''' + import signal, time + def term(*a): + mark("term") + sys.exit(0) + signal.signal(signal.SIGTERM, term) + open(os.path.join(HERE, "ready"), "w").close() + time.sleep(30) + ''')) + def send(): + for _ in range(300): + if os.path.exists(self.path("ready")): + break + time.sleep(0.05) + self.l.on_sigterm(signal.SIGTERM, None) + t = threading.Thread(target=send) + t.start() + self.assertEqual(self.l.run(), 0) + t.join() + self.assertEqual(self.runs(), ["v5", "term"]) + + +class RunPyIsCopyTest(unittest.TestCase): + """run.py — дословная копия запускателя jira-agent (design 2): правки общих частей делаются в обоих репозиториях.""" + + def test_run_py_is_copy(self): + # от worktree (<корень>/eva-agent/.wt/<срез>) и от основного checkout (<корень>/eva-agent) — разная глубина + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + candidates = [os.path.normpath(os.path.join(here, up, "jira-agent", "src", "run.py")) + for up in ("..", os.path.join("..", ".."), os.path.join("..", "..", ".."))] + origin = next((c for c in candidates if os.path.exists(c)), None) + if origin is None: + self.skipTest("рядом нет jira-agent/src/run.py (искал: %s)" % ", ".join(candidates)) + with open(origin, "rb") as a, open(os.path.join(SRC, "run.py"), "rb") as b: + self.assertEqual(a.read(), b.read(), "src/run.py разошёлся с %s" % origin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_py39.py b/tests/test_py39.py new file mode 100644 index 0000000..03c76c9 --- /dev/null +++ b/tests/test_py39.py @@ -0,0 +1,33 @@ +import ast +import os +import unittest + +from _common import SRC + +# Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние +# импорты; API и конструкции новее 3.9 (например, `X | Y` — синтаксис 3.10: аннотации функций +# вычисляются при импорте и их ловит прогон на /usr/bin/python3 3.9.6, аннотации локальных переменных не вычисляются), не проверяет. + + +class Py39Test(unittest.TestCase): + FILES = ("agent.py", "run.py") + + def test_parses_as_python_39(self): + for name in self.FILES: + with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f: + ast.parse(f.read(), feature_version=(3, 9)) + + def test_no_third_party_imports(self): + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "subprocess"} + for name in self.FILES: + with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f: + tree = ast.parse(f.read()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + self.assertIn(a.name.split(".")[0], allowed, "%s: %s" % (name, a.name)) + elif isinstance(node, ast.ImportFrom): + self.assertIn((node.module or "").split(".")[0], allowed, "%s: %s" % (name, node.module)) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..cffccf1 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,563 @@ +import json +import os +import tempfile +import threading +import time +import unittest +from unittest import mock +from datetime import datetime, timedelta, timezone + +from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_hub import FakeHub + + +FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"} +STUB_ERROR = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed + + +class RunnerTest(unittest.TestCase): + def setUp(self): + self.hub = FakeHub(SETTINGS) + self.dir = tempfile.TemporaryDirectory() + self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl")) + cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token) + self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal) + self.sleeps = [] + self.runner.sleep = self.sleeps.append + self.runner.eva.sleep = no_sleep + self.runner.idle_limit = 1 + + def tearDown(self): + self.hub.stop() + self.dir.cleanup() + + def kinds(self): + return [(r["kind"], r["command_id"]) for r in self.journal.records()] + + def claim_in_flight(self, ctype, params, pause=0): + cid = self.hub.enqueue(ctype, params, pause=pause) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + self.hub.in_flight = cid + return cid + + def test_processes_queue(self): + self.hub.enqueue("wiki_fetch", FETCH) + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertEqual(self.hub.results[1]["error"], STUB_ERROR) + self.assertEqual(self.hub.results[1]["jira_calls"], 0) # поле протокола хаба: число вызовов Eva + self.assertEqual(self.hub.results[2]["status"], "failed") + self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)]) + self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 + + def test_claim_sends_update_state_and_launcher(self): + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s") + self.runner.loop() + body = self.hub.claim_bodies[0] + self.assertEqual(body["launcher_version"], 1) + self.assertEqual(body["update_state"]["state"], "trial") + self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim + self.assertEqual(self.hub.claim_bodies[0]["agent_version"], agent.AGENT_VERSION) + + def test_claim_without_updater_sends_nulls(self): + self.runner.loop() + self.assertIsNone(self.hub.claim_bodies[0]["update_state"]) + self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"]) + + def test_idle_with_release_exits_for_update(self): + rel = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"} + self.hub.release = rel + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + self.runner.updater.check = lambda r: r == {"version": agent.AGENT_VERSION + 1, "sha256": "s"} + self.runner.idle_limit = 5 + self.runner.loop() + self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE) + self.assertEqual(self.hub.claims, 1) + + def test_stop_during_check_exits_0(self): + self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"} + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + def check(r): + self.runner.stop = True # Ctrl-C пришёл, когда версия уже staged + return True + self.runner.updater.check = check + self.runner.idle_limit = 5 + self.runner.loop() + self.assertEqual(self.runner.exit_code, 0) # остановка человеком; staged доделает run.py при старте + + def test_command_first_then_update(self): + self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"} + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + seen = [] + self.runner.updater.check = lambda r: (seen.append(r), True)[1] + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") # команда выполнена и подтверждена + self.assertEqual(len(seen), 1) # проверка — только после claim без команды + self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE) + + def test_restart_pace_from_old_finished(self): + at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None, + "jira_calls": 1, "rate_limited": 0, "at": at}) + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("wiki_fetch", FETCH, pause=30) + self.runner.loop() + self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5) + + def test_future_journal_time_means_full_pause(self): + at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("wiki_fetch", FETCH, pause=30) + self.runner.loop() + self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12) + self.assertEqual(self.hub.results[1]["status"], "failed") + + def test_claim_after_restart_sends_no_journal_estimate(self): + at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("wiki_fetch", FETCH) + eva = self.runner.eva + with mock.patch.object(agent.Executor, "do_wiki_fetch", + lambda ex, p, cmd: (eva._touch(), ex._out("done", {"file": "abc", "size": 3}))[1]): + self.runner.loop() + self.assertIsNone(self.hub.claim_bodies[0].get("last_call_at")) # оценка журнала — только для паузы (M-7 #12) + self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_call_at")) # после вызова Eva — настоящее время + self.assertIsNotNone(self.runner.eva.last_call_at) + + def test_restore_pace_keeps_existing_last_call(self): + at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + mark = time.monotonic() + self.runner.eva.last_call_at = mark + self.runner.restore_pace() + self.assertEqual(self.runner.eva.last_call_at, mark) + + def test_pause_waits_from_last_eva_call(self): + self.runner.eva.last_call_at = time.monotonic() + self.hub.enqueue("wiki_fetch", FETCH, pause=3) + self.runner.loop() + self.assertEqual(len(self.sleeps), 3) + self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1) + self.assertEqual(self.hub.results[1]["status"], "failed") + + def test_result_retried_when_hub_down(self): + self.hub.fail_results = 2 + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertEqual(self.sleeps, [10, 10]) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_result_retried_on_connection_error(self): + calls = [] + real = self.runner.hub.result + + def flaky(cid, payload): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return real(cid, payload) + + self.runner.hub.result = flaky + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.sleeps, [10, 10]) + self.assertEqual(self.hub.results[1]["status"], "failed") + + def test_result_rejected_is_acked(self): + for cid, code in ((1, 404), (2, 409), (3, 422)): + self.hub.reject_results = {cid: code} + self.journal.finished(cid, "done", {}, None, 0, 0) + self.assertTrue(self.runner.send_result(self.journal.unacked_finished()[0])) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_result_rejected_in_loop(self): + self.hub.reject_results = {1: 404} + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertNotIn(1, self.hub.results) + self.assertEqual(self.journal.unacked_finished(), []) + self.assertEqual(self.kinds()[-1], ("acked", 1)) + + def test_recover_unfinished_started(self): + cid = self.claim_in_flight("wiki_fetch", FETCH, pause=99) + self.journal.started(cid) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["error"], STUB_ERROR) # команда выполнена заново, как решено в design 9 + self.assertEqual(self.journal.unfinished_started(), []) + self.assertAlmostEqual(sum(self.sleeps), 99, delta=1) # пауза из хаба, от записи started (M-9) + + def test_recover_does_not_journal_claimed_twice(self): + cid = self.claim_in_flight("wiki_fetch", FETCH, pause=0) + self.journal.claimed(cid) + self.runner.loop() + self.assertEqual(self.kinds(), [("claimed", cid), ("started", cid), ("finished", cid), ("acked", cid)]) + + def test_recover_skips_command_not_claimed_anymore(self): + cid = self.hub.enqueue("wiki_fetch", FETCH) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "cancelled" + self.journal.started(cid) + self.runner.loop() + self.assertNotIn(cid, self.hub.results) + self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)]) + + def test_recover_unacked_finished(self): + cid = self.claim_in_flight("wiki_fetch", FETCH) + self.journal.started(cid) + self.journal.finished(cid, "done", {"file": "abc", "size": 3}, None, 2, 0) + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["result"], {"file": "abc", "size": 3}) + self.assertEqual(self.hub.results[2]["status"], "failed") + self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)]) + + def test_stop_during_long_claim(self): + self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30) + self.runner.idle_limit = None + threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start() + start = time.monotonic() + self.runner.loop() + self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim + self.assertEqual(self.hub.claims, 1) + self.assertEqual(self.journal.records(), []) + + def test_command_arrives_during_long_claim(self): + self.hub.claim_wait = 5.0 + + def arrive(): + self.hub.enqueue("wiki_fetch", FETCH) + self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания + threading.Timer(0.3, arrive).start() + start = time.monotonic() + self.runner.loop() + self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту + self.assertEqual(self.hub.results[1]["status"], "failed") + + def test_stop_during_pause(self): + self.runner.eva.last_call_at = time.monotonic() + self.hub.enqueue("wiki_fetch", FETCH, pause=5) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.runner.loop() + self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) # started нет + self.assertEqual(self.hub.results, {}) + self.assertEqual(self.hub.commands[1]["status"], "claimed") + + def test_stop_during_pause_then_restart_executes_once(self): + self.runner.eva.last_call_at = time.monotonic() + self.hub.enqueue("wiki_fetch", FETCH, pause=5) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.runner.loop() + self.assertEqual(self.journal.unfinished_started(), [1]) + cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token) + second = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), agent.Journal(self.journal.path)) + second.sleep = self.sleeps.append + second.idle_limit = 1 + second.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertEqual(len([r for r in self.journal.records() if r["kind"] == "finished"]), 1) + self.assertEqual(self.journal.unfinished_started(), []) + self.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9) + + def test_stop_between_claim_and_execute_is_recorded(self): + self.hub.enqueue("wiki_fetch", FETCH) + real = self.runner.hub.claim + + def claim_then_stop(*a): + resp = real(*a) + self.runner.stop = True + return resp + + self.runner.hub.claim = claim_then_stop + self.runner.loop() + self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) + self.assertEqual(self.journal.unfinished_started(), [1]) + + def test_stop_during_execution_finishes_and_reports(self): + self.hub.enqueue("wiki_fetch", FETCH) + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.load_settings() + run = self.runner.executor.run + + def run_and_signal(cmd): + self.runner.stop = True # сигнал пришёл во время выполнения + return run(cmd) + + self.runner.executor.run = run_and_signal + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertNotIn(2, self.hub.results) # вторая команда не начата + self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1)]) + + def test_stop_during_execution_hub_down_single_attempt(self): + self.hub.fail_results = 5 + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.load_settings() + run = self.runner.executor.run + self.runner.executor.run = lambda cmd: (setattr(self.runner, "stop", True), run(cmd))[1] + self.runner.loop() + self.assertEqual(self.sleeps, []) + self.assertEqual([r["command_id"] for r in self.journal.unacked_finished()], [1]) + + def test_unexpected_exception_becomes_failed(self): + self.runner.load_settings() + + def boom(cmd): + raise KeyError("summary") + + self.runner.executor.run = boom + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertIn("KeyError", self.hub.results[1]["error"]) + self.assertEqual(self.journal.unfinished_started(), []) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_recover_waits_when_hub_down(self): + cid = self.claim_in_flight("wiki_fetch", FETCH) + self.journal.started(cid) + real = self.runner.hub.command + calls = [] + + def flaky(i): + calls.append(1) + if len(calls) == 1: + raise ConnectionError("нет связи") + return real(i) + + self.runner.hub.command = flaky + self.runner.loop() + self.assertEqual(self.sleeps, [10]) + self.assertEqual(self.hub.results[cid]["status"], "failed") + + def test_settings_reload_on_version_change(self): + self.runner.load_settings() + self.runner.settings["settings_version"] = 0 + self.runner.loop() + self.assertEqual(self.runner.settings["settings_version"], 1) + + def test_settings_reloaded_before_command(self): + self.runner.load_settings() + self.hub.settings["settings_version"] = 2 + self.hub.settings["wiki_pacing"] = 0.5 + self.hub.enqueue("wiki_fetch", FETCH) + seen = [] + real = self.runner.execute + self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd)) + self.runner.loop() + self.assertEqual(seen, [2]) + + def test_hub_down_at_start_keeps_polling(self): + calls = [] + + def hub_settings(): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return dict(SETTINGS) + + self.runner.hub.settings = hub_settings + self.runner.loop() + self.assertEqual(len(calls), 3) + self.assertEqual(self.sleeps, [1, 1]) # пол паузы на ошибках хаба — 1 с + + def test_retry_waits_from_failure_even_if_last_call_is_old(self): + # F-1: Eva недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) — + # ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова. + self.runner.load_settings() + self.runner.eva.last_call_at = time.monotonic() - 600 + runs = [] + + def flaky(cmd): + runs.append(1) + if len(runs) == 1: + return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0, + "rate_limited": 0, "retryable": True} + return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0} + + self.runner.executor.run = flaky + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(len(runs), 2) + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа + + def test_retry_exhausted_gives_failed_unknown(self): + self.runner.load_settings() + runs = [] + + def down(cmd): + runs.append(1) + return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 1, "rate_limited": 0, "retryable": True} + + self.runner.executor.run = down + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(len(runs), 4) # первый запуск + 3 повтора + r = self.hub.results[1] + self.assertEqual(r["status"], "failed") + self.assertIn("результат неизвестен", r["error"]) + self.assertIn("(обрыв связи с Eva)", r["error"]) + self.assertEqual(r["jira_calls"], 4) + + def test_stop_during_retry_wait_leaves_command_for_recovery(self): + self.runner.load_settings() + self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "x", "jira_calls": 1, + "rate_limited": 0, "retryable": True} + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.journal.unfinished_started(), [1]) + self.assertEqual(self.hub.results, {}) + + def test_agent_version_in_result(self): + self.hub.enqueue("wiki_fetch", FETCH) + self.runner.loop() + self.assertEqual(self.hub.results[1]["agent_version"], agent.AGENT_VERSION) + + def test_hub_auth_error_waits_60(self): + def denied(*a): + raise agent.HttpError(401, "unauthorized") + + self.runner.hub.claim = denied + self.runner.sleep = lambda s: (self.sleeps.append(s), setattr(self.runner, "stop", True)) + self.runner.loop() + self.assertEqual(self.sleeps, [60]) + + def test_wrong_hub_token_401_real_hub(self): + cfg = eva_cfg(hub_url=self.hub.base_url, hub_token="bad") + r = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal) + r.sleep = lambda s: (self.sleeps.append(s), setattr(r, "stop", True)) + r.loop() + self.assertEqual(self.sleeps, [60]) + + def test_settings_reload_failure_retried_before_command(self): + self.runner.load_settings() + self.hub.settings["settings_version"] = 2 + self.hub.enqueue("wiki_fetch", FETCH) + real = self.runner.hub.settings + calls = [] + + def flaky(): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return real() + + self.runner.hub.settings = flaky + self.runner.loop() + self.assertEqual(len(calls), 3) + self.assertEqual(self.sleeps, [1, 1]) + self.assertEqual(self.runner.settings["settings_version"], 2) + self.assertEqual(self.hub.results[1]["status"], "failed") + + def test_claim_timeout_at_least_45(self): + seen = [] + real = agent.Http.call + agent.Http.call = lambda self_, m, p, body=None, params=None, timeout=None: (seen.append(timeout), {"command": None, "settings_version": 1})[1] + try: + agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t", timeout=5)).claim(1, 1, None) + finally: + agent.Http.call = real + self.assertEqual(seen, [45]) + + def test_signal_handler(self): + import signal + from unittest import mock + handlers = {} + seen = {} + + def fake_loop(runner): + handlers[signal.SIGINT](signal.SIGINT, None) + seen["stop_after_first"] = runner.stop + with self.assertRaises(SystemExit) as cm: + handlers[signal.SIGINT](signal.SIGINT, None) + seen["code"] = cm.exception.code + + cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token) + with mock.patch.object(agent.signal, "signal", lambda n, h: handlers.__setitem__(n, h)), \ + mock.patch.object(agent.Runner, "loop", fake_loop): + agent.cmd_run(cfg, self.journal.path) + self.assertTrue(seen["stop_after_first"]) + self.assertEqual(seen["code"], 130) + + + def test_prune_refused_while_agent_runs(self): + old = "2020-01-01T00:00:00+00:00" + self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None, + "jira_calls": 1, "rate_limited": 0, "at": old}) + self.journal.append({"kind": "acked", "command_id": 7, "at": old}) + held = agent.acquire_lock(self.journal.path) + try: + self.assertEqual(agent.cmd_prune(self.journal.path), 1) + self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4) + finally: + held.close() + self.assertEqual(agent.cmd_prune(self.journal.path), 0) + self.assertEqual(self.journal.records(), []) + + def test_second_process_refused(self): + """I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду.""" + from unittest import mock + held = agent.acquire_lock(self.journal.path) + self.assertIsNotNone(held) + try: + self.assertIsNone(agent.acquire_lock(self.journal.path)) + cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token) + with mock.patch.object(agent.Runner, "loop", lambda r: self.fail("цикл не должен стартовать")): + self.assertEqual(agent.cmd_run(cfg, self.journal.path), agent.EXIT_LOCKED) + self.assertEqual(agent.EXIT_LOCKED, 73) + finally: + held.close() + again = agent.acquire_lock(self.journal.path) + self.assertIsNotNone(again) # после выхода первого — снова можно + again.close() + + def test_future_journal_time_single_log_line(self): + at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + logs = [] + orig = agent.log + agent.log = logs.append + try: + self.runner.restore_pace() + finally: + agent.log = orig + self.assertEqual(len(logs), 1, logs) # M-2 #18: без «не позже <будущее>» + self.assertIn("в будущем", logs[0]) + self.assertIsNotNone(self.runner.eva.last_call_at) + + def test_future_journal_time_then_stop_in_pause(self): + at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("wiki_fetch", FETCH, pause=30) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) # Ctrl-C в первую секунду паузы + self.runner.loop() + self.assertEqual([r["kind"] for r in self.journal.records() if r["command_id"] == 1], ["claimed"]) + self.assertEqual(self.runner.eva.calls, 0) # к Eva ни одного вызова (M-3 #18) + self.assertEqual(self.hub.commands[1]["status"], "claimed") + + def test_retry_exhausted_on_hub_names_hub(self): + self.runner.load_settings() + logs = [] + orig = agent.log + agent.log = logs.append + self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "хаб: не ответил", + "jira_calls": 0, "rate_limited": 0, "retryable": True, "peer": "хабом"} + self.hub.enqueue("wiki_fetch", FETCH) + try: + self.runner.loop() + finally: + agent.log = orig + r = self.hub.results[1] + self.assertEqual(r["error"], "результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил") + self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs) + self.assertNotIn("peer", r) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py new file mode 100644 index 0000000..e0cad78 --- /dev/null +++ b/tests/test_self_update_e2e.py @@ -0,0 +1,78 @@ +import hashlib +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +from _common import SETTINGS, SRC +from fake_hub import FakeHub + + +def wait_for(pred, timeout=30): + end = time.monotonic() + timeout + while time.monotonic() < end: + if pred(): + return True + time.sleep(0.1) + return False + + +class SelfUpdateE2E(unittest.TestCase): + """run.py + настоящий agent.py: v видит публикацию v, ставит её, v подтверждает; Ctrl-C группе — выход 0.""" + + def setUp(self): + self.hub = FakeHub(SETTINGS) + self.tmp = tempfile.TemporaryDirectory() + d = self.tmp.name + shutil.copy(os.path.join(SRC, "run.py"), d) + shutil.copy(os.path.join(SRC, "agent.py"), d) + with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f: + json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2, + "eva": {"base_url": "http://127.0.0.1:1", "token": "eva-secret"}}, f) + with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: + src = f.read() + self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1)) + cur = "AGENT_VERSION = %d\n" % self.cur + content = src.replace(cur, "AGENT_VERSION = %d\n" % (self.cur + 1), 1) + self.assertNotEqual(content, src) + self.hub.release = {"version": self.cur + 1, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + "content": content} + self.content = content + self.log = open(os.path.join(d, "out.log"), "wb") + self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log, + stderr=subprocess.STDOUT, start_new_session=True) + + def tearDown(self): + if self.proc.poll() is None: + os.killpg(self.proc.pid, signal.SIGKILL) + self.proc.wait() + self.log.close() + self.hub.stop() + self.tmp.cleanup() + + def state(self): + try: + with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return None + + def test_update_to_next_and_stop_by_ctrl_c(self): + ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60) + with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f: + out = f.read() + self.assertTrue(ok, out) + with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f: + self.assertEqual(f.read(), self.content) + self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev"))) + self.assertTrue(wait_for(lambda: any(b.get("agent_version") == self.cur + 1 for b in self.hub.claim_bodies))) + last = [b for b in self.hub.claim_bodies if b.get("agent_version") == self.cur + 1][-1] + self.assertEqual(last["launcher_version"], 2) + os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе + self.assertEqual(self.proc.wait(30), 0) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..773199c --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,303 @@ +import hashlib +import json +import os +import tempfile +import unittest + +from _common import SETTINGS, agent +from fake_hub import FakeHub + +CUR = agent.AGENT_VERSION +NEXT = CUR + 1 + + +class UpdateStateTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "update.json") + + def tearDown(self): + self.dir.cleanup() + + def test_write_and_read(self): + d = agent.write_update_state(self.path, "staged", 5, 6, "abc") + self.assertEqual(agent.read_update_state(self.path), d) + self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None)) + self.assertTrue(d["at"]) + self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался + + def test_read_missing(self): + self.assertIsNone(agent.read_update_state(self.path)) + + def test_read_update_state_garbage(self): + for text in ('{"state": "tri', "[1, 2]", ""): + with open(self.path, "w", encoding="utf-8") as f: + f.write(text) + self.assertIsNone(agent.read_update_state(self.path)) + + def test_confirm_trial_of_this_version(self): + u = agent.Updater(None, self.dir.name, 1) + agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s") + u.confirm() + st = agent.read_update_state(u.state_path) + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s")) + + def test_confirm_write_error_only_logged(self): + """M-3 финального ревью: диск полон в момент подтверждения — агент не падает, подтвердит на следующем claim.""" + u = agent.Updater(None, self.dir.name, 1) + agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s") + logs = [] + + def full(*a, **kw): + raise OSError(28, "No space left on device") + orig_log, orig_write = agent.log, agent.write_update_state + agent.log, agent.write_update_state = logs.append, full + try: + u.confirm() + finally: + agent.log, agent.write_update_state = orig_log, orig_write + self.assertEqual(u.state()["state"], "trial") + self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs) + u.confirm() + self.assertEqual(u.state()["state"], "ok") + + def test_confirm_ignores_other_states(self): + u = agent.Updater(None, self.dir.name, 1) + u.confirm() + self.assertIsNone(u.state()) # нет файла — не создаётся + agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s") + u.confirm() + self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша + agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с") + u.confirm() + self.assertEqual(u.state()["state"], "rolled_back") + + +def make_release(version, body="print('ok')\n"): + content = "AGENT_VERSION = %d\n%s" % (version, body) + return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content} + + +class UpdaterCheckTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.hub = FakeHub(SETTINGS) + cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5} + self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1) + self.selftests = [] + self.code = 0 + self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1] + self.now = [1000.0] + self.u.clock = lambda: self.now[0] + + def tearDown(self): + self.hub.stop() + self.dir.cleanup() + + def ref(self, rel): + return {"version": rel["version"], "sha256": rel["sha256"]} + + def capture_logs(self): + logs = [] + orig = agent.log + agent.log = logs.append + self.addCleanup(setattr, agent, "log", orig) + return logs + + def test_incomplete_release_ignored_with_hourly_hint(self): + logs = self.capture_logs() + for ref in ({"version": NEXT, "sha256": None}, {"version": str(NEXT), "sha256": "a" * 64}, + {"version": True, "sha256": "a" * 64}, {"version": NEXT, "sha256": "A" * 64}, + {"version": NEXT, "sha256": "a" * 63}, {"version": NEXT, "sha256": "a" * 64 + "\n"}): + self.assertFalse(self.u.check(ref)) + self.now[0] += 3600 + self.assertFalse(self.u.check({"version": NEXT, "sha256": None})) + self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 2) + self.assertEqual(self.hub.release_requests, 0) + self.assertIsNone(self.u.state()) + + def test_incomplete_release_hint_is_bounded(self): + """M-4: подсказка не печатает произвольно длинный мусор от хаба целиком.""" + logs = self.capture_logs() + self.assertFalse(self.u.check({"version": NEXT, "sha256": "x" * 10000})) + hints = [l for l in logs if l.startswith("хаб прислал неполную публикацию")] + self.assertEqual(len(hints), 1) + self.assertLess(len(hints[0]), 300) + + def test_hints_have_separate_timers(self): + logs = self.capture_logs() + self.u.launcher_version = None + self.hub.release = make_release(NEXT) + self.assertFalse(self.u.check({"version": NEXT, "sha256": None})) + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 1) + self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 1) + + def test_write_error_is_transient(self): + self.hub.release = make_release(NEXT) + ref = self.ref(self.hub.release) + self.u.new_path = os.path.join(self.dir.name, "нет-каталога", "agent.py.new") # open() → FileNotFoundError + self.assertFalse(self.u.check(ref)) + self.assertIsNone(self.u.state()) + self.assertEqual(self.selftests, []) + self.now[0] += 299 + self.assertFalse(self.u.check(ref)) + self.assertEqual(self.hub.release_requests, 1) # раньше 5 минут не повторяет + + def test_selftest_os_error_is_transient(self): + self.hub.release = make_release(NEXT) + def boom(path): + raise OSError(28, "No space left on device") + self.u.run_selftest = boom + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertIsNone(self.u.state()) + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_staged_write_error_is_transient(self): + self.hub.release = make_release(NEXT) + self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json") + self.assertFalse(self.u.check(self.ref(self.hub.release))) # без staged выходить с 75 нельзя + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_reject_write_error_only_logged(self): + rel = make_release(NEXT) + self.hub.release = dict(rel, content=rel["content"] + "# подмена\n") + self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json") + logs = self.capture_logs() + self.assertFalse(self.u.check(self.ref(rel))) # не бросает + self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs) + + def test_good_version_is_staged(self): + rel = make_release(NEXT) + self.hub.release = rel + self.assertTrue(self.u.check(self.ref(rel))) + with open(self.u.new_path, encoding="utf-8") as f: + self.assertEqual(f.read(), rel["content"]) + st = self.u.state() + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", CUR, NEXT, rel["sha256"])) + self.assertEqual(self.selftests, [self.u.new_path]) + + def test_not_newer_or_disabled_or_no_release(self): + self.hub.release = make_release(CUR) + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertFalse(self.u.check(None)) + self.hub.release = make_release(NEXT) + self.u.enabled = False + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertEqual(self.hub.release_requests, 0) + + def test_sha_mismatch_rejected_once(self): + rel = make_release(NEXT) + self.hub.release = dict(rel, content=rel["content"] + "# подмена\n") + ref = self.ref(rel) + self.hub.release["sha256"] = rel["sha256"] + self.assertFalse(self.u.check(ref)) + st = self.u.state() + self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", NEXT, "sha256 не сходится")) + self.assertFalse(os.path.exists(self.u.new_path)) + self.assertFalse(self.u.check(ref)) + self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает + + def test_syntax_error_and_wrong_version_rejected(self): + bad = make_release(NEXT, body="def (:\n") + self.hub.release = bad + self.assertFalse(self.u.check(self.ref(bad))) + self.assertIn("не компилируется", self.u.state()["error"]) + wrong = make_release(NEXT + 1) + wrong = dict(wrong, version=NEXT + 2) + self.hub.release = wrong + self.assertFalse(self.u.check(self.ref(wrong))) + self.assertIn("AGENT_VERSION", self.u.state()["error"]) + self.assertEqual(self.selftests, []) + + def test_selftest_bad_code_rejected(self): + self.hub.release = make_release(NEXT) + self.code = 1 + self.assertFalse(self.u.check(self.ref(self.hub.release))) + st = self.u.state() + self.assertEqual(st["state"], "rejected") + self.assertTrue(st["error"].startswith("selftest: код 1")) + self.assertIn("вывод selftest", st["error"]) + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_selftest_unavailable_is_transient(self): + self.hub.release = make_release(NEXT) + self.code = 3 + ref = self.ref(self.hub.release) + self.assertFalse(self.u.check(ref)) + self.assertIsNone(self.u.state()) # update.json не тронут + self.assertFalse(os.path.exists(self.u.new_path)) + self.now[0] += 299 + self.assertFalse(self.u.check(ref)) + self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет + self.now[0] += 2 + self.code = 0 + self.assertTrue(self.u.check(ref)) + + def test_selftest_timeout_is_transient(self): + self.hub.release = make_release(NEXT) + self.code = None + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertIsNone(self.u.state()) + + def test_download_failure_is_transient(self): + rel = make_release(NEXT) # в claim есть, а /agent/release — 404 + self.assertFalse(self.u.check(self.ref(rel))) + self.assertIsNone(self.u.state()) + + def test_release_changed_between_requests_is_transient(self): + old, new = make_release(NEXT), make_release(NEXT + 1) + self.hub.release = new + self.assertFalse(self.u.check(self.ref(old))) + self.assertIsNone(self.u.state()) + self.assertEqual(self.selftests, []) + + def test_rolled_back_same_sha_not_retried(self): + rel = make_release(NEXT) + self.hub.release = rel + agent.write_update_state(self.u.state_path, "rolled_back", CUR, NEXT, rel["sha256"], "код 1 через 3 с") + self.assertFalse(self.u.check(self.ref(rel))) + fixed = make_release(NEXT, body="print('fixed')\n") + self.hub.release = fixed + self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем + + def test_without_launcher_only_hint(self): + self.u.launcher_version = None + self.hub.release = make_release(NEXT) + logs = [] + orig = agent.log + agent.log = logs.append + try: + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.now[0] += 3600 + self.assertFalse(self.u.check(self.ref(self.hub.release))) + finally: + agent.log = orig + self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 2) + self.assertEqual(self.hub.release_requests, 0) + + def test_stop_during_selftest_is_not_reject(self): + self.hub.release = make_release(NEXT) + stop = [False] + self.u.should_stop = lambda: stop[0] + + def selftest(path): + stop[0] = True # Ctrl-C пришёл и selftest упал с 1 + return 1, "KeyboardInterrupt" + self.u.run_selftest = selftest + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertIsNone(self.u.state()) + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_real_selftest_runs_new_file(self): + u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json")) + path = os.path.join(self.dir.name, "agent.py.new") + with open(path, "w", encoding="utf-8") as f: + f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n") + code, out = u._run_selftest(path) + self.assertEqual(code, 3) + self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out) + +if __name__ == "__main__": + unittest.main() -- 2.47.2 From 7bc59f3f32c41a0bb6bd1496cd3c2bf5e5297418 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:49:50 +0400 Subject: [PATCH 04/15] =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20Ev?= =?UTF-8?q?a=20JSON-RPC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 14 +++ src/agent.py | 74 ++++++++++- tests/fake_eva.py | 134 ++++++++++++++++++++ tests/test_clients.py | 22 ++-- tests/test_eva_client.py | 150 +++++++++++++++++++++++ tests/test_runner.py | 1 + 6 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 tests/fake_eva.py create mode 100644 tests/test_eva_client.py diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 6400602..9dc2283 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -139,3 +139,17 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов. 19. **`.gitignore` дополнен `agent.py.new`, `agent.py.prev`, `__pycache__/`, `*.pyc`** (рабочие файлы самообновления и кэш). Цена ошибки: нет. +20. **`EvaClient(cfg)` остаётся с сигнатурой каркаса (по всему `cfg`), а не `(cfg_eva, timeout, verify_ssl, ca_bundle)` из брифа.** + Обоснование: `Runner`/`main` уже создают клиента как `EvaClient(cfg)` (решение 14). + Цена ошибки: нет, внутренняя сигнатура. +21. **`EvaClient.call(method, **kwargs)` переопределяет `Http.call(method, path, ...)`.** Транспортный вызов — через + `Http.call(self, ...)`; тесты общего `Http` в `test_clients.py` переведены на `agent.Http.call(client, ...)`. + Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом). + Цена ошибки: путаница сигнатур; видна при чтении класса. +22. **Пауза между вызовами — перед вызовом (и перед повтором) по `last_call_at`; первый вызов после старта не + задерживается. `Runner.load_settings` ставит `eva.pacing` из `wiki_pacing` (по умолчанию 0.2).** + Обоснование: `last_call_at` общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой. +23. **Токен вырезается (`***`) из текстов `HttpError`/`ConnectionError`/`EvaError`, выходящих из `EvaClient`.** + Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. +24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.** + Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. diff --git a/src/agent.py b/src/agent.py index 909ffc9..7e8df37 100644 --- a/src/agent.py +++ b/src/agent.py @@ -228,9 +228,16 @@ class Http: """Вызывается на каждый полученный HTTP-ответ (любой код).""" +class EvaError(Exception): + """JSON-RPC Eva вернула `error`: текст «<метод>: ».""" + + class EvaClient(Http): - """Клиент Eva: счётчики вызовов и 429, время последнего вызова (для паузы между вызовами). Методы JSON-RPC - добавляются в задачах 2–3; здесь — только каркас, общий для цикла и Executor.""" + """Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова.""" + PAGE_SIZE = 200 + RETRY_CODES = (429, 502, 503, 504) + ATTEMPTS = 4 + MAX_RETRY_AFTER = 60 def __init__(self, cfg: dict): e = cfg["eva"] @@ -242,8 +249,10 @@ class EvaClient(Http): elif cfg.get("ca_bundle"): ctx = ssl.create_default_context(cafile=cfg["ca_bundle"]) super().__init__(e["base_url"], {"Authorization": "Bearer " + e["token"]}, cfg.get("timeout", 60), ctx) + self._token = e["token"] self.calls = 0 self.rate_limited = 0 + self.pacing = 0.0 # с между вызовами; ставит Runner из wiki_pacing self.last_call_at = None # type: Optional[float] (time.monotonic) self.last_call_iso = None # type: Optional[str] self.sleep = time.sleep @@ -255,6 +264,66 @@ class EvaClient(Http): def reset_counters(self) -> None: self.calls, self.rate_limited = 0, 0 + def _scrub(self, text: str) -> str: + return text.replace(self._token, "***") if self._token else text + + def _retrying(self, fn: Callable[[], Any]) -> Any: + """Вызов с паузой между вызовами и повторами: 429/502/503/504 и сетевые сбои, 4 попытки, ожидание 1, 2, 4 с + (при 429 — Retry-After, не больше 60 с). Тексты ошибок очищаются от токена.""" + for attempt in range(self.ATTEMPTS): + if self.pacing > 0 and self.last_call_at is not None: + wait = self.pacing - (time.monotonic() - self.last_call_at) + if wait > 0: + self.sleep(wait) + wait = float(2 ** attempt) + try: + return fn() + except HttpError as e: + if e.code not in self.RETRY_CODES: + raise HttpError(e.code, self._scrub(e.text), e.headers) from None + if e.code == 429: + self.rate_limited += 1 + try: + wait = min(max(float(e.headers.get("retry-after", wait)), 0.0), self.MAX_RETRY_AFTER) + except ValueError: + pass + if attempt == self.ATTEMPTS - 1: + raise HttpError(e.code, self._scrub(e.text), e.headers) from None + except ConnectionError as e: + if attempt == self.ATTEMPTS - 1: + raise ConnectionError(self._scrub(str(e))) from None + self.sleep(wait) + raise AssertionError("недостижимо") + + def call(self, method: str, **kwargs) -> Any: # type: ignore[override] + """Метод JSON-RPC: POST /api/?m=<метод>. `error` в ответе — EvaError.""" + body = {"jsonrpc": "2.2", "method": method, "callid": str(uuid.uuid4()), "kwargs": kwargs} + data = self._retrying(lambda: Http.call(self, "POST", "/api/", body, {"m": method})) + if isinstance(data, dict) and data.get("error"): + raise EvaError(self._scrub("%s: %s" % (method, data["error"]))) + return data.get("result") if isinstance(data, dict) else None + + def get_doc(self, code: str, fields: List[str]) -> Optional[dict]: + return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields) + + def list_docs(self, flt: list, fields: List[str]) -> List[dict]: + out, offset = [], 0 # type: List[dict], int + while True: + chunk = self.call("CmfDocument.list", filter=flt, fields=fields, + slice=[offset, offset + self.PAGE_SIZE]) or [] + if isinstance(chunk, dict): # обёрнутый результат + chunk = chunk.get("items") or chunk.get("result") or [] + out.extend(chunk) + if len(chunk) < self.PAGE_SIZE: + return out + offset += self.PAGE_SIZE + + def download(self, url: str, limit: int) -> bytes: + """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста.""" + if _origin(url) != _origin(self.base) or not url.startswith(self.base): + raise ValueError("чужой хост") + return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) + class FolderTransport: """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). @@ -764,6 +833,7 @@ class Runner: self.settings = self.hub.settings() self.executor = Executor(self.eva, self.hub, self.settings) self.executor.sleep = self.sleep + self.eva.pacing = float(self.settings.get("wiki_pacing", 0.2)) log("настройки хаба v%s" % self.settings.get("settings_version")) def restore_pace(self) -> None: diff --git a/tests/fake_eva.py b/tests/fake_eva.py new file mode 100644 index 0000000..28afa24 --- /dev/null +++ b/tests/fake_eva.py @@ -0,0 +1,134 @@ +"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server.""" +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + + +def _field(doc, path): + """Значение поля по пути `project.code`: ссылка хранится как {"id", "code"}.""" + cur = doc + for part in path.split("."): + if not isinstance(cur, dict): + return None + cur = cur.get(part) + return cur + + +class FakeEva: + token = "eva-secret" + + def __init__(self): + self.docs = [] + self.files = {} # путь → (байты, с Content-Length?) + self.requests = [] # {"method", "path", "body", "auth"} + self.failures = [] # [status, retry_after] — по одному на ответ + self.rpc_errors = [] + self.lock = threading.Lock() + eva = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + eva.handle(self, "GET") + + def do_POST(self): + eva.handle(self, "POST") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + self.server.server_close() + + def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""): + ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731 + self.docs.append({"id": id, "code": code, "name": name, "parent": ref(parent), + "tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno, + "text": text}) + + def add_file(self, path, data, length=True): + self.files[path] = (data, length) + + def fail_next(self, status, times=1, retry_after=None): + self.failures.extend([[status, retry_after]] * times) + + def rpc_error_next(self, text): + self.rpc_errors.append(text) + + @property + def api_calls(self): + return [r for r in self.requests if r["path"].startswith("/api/")] + + def handle(self, h, verb): + u = urlsplit(h.path) + n = int(h.headers.get("Content-Length") or 0) + raw = h.rfile.read(n) if n else b"" + auth = h.headers.get("Authorization") + with self.lock: + self.requests.append({"method": verb, "path": h.path, + "body": json.loads(raw) if raw else None, "auth": auth}) + failure = self.failures.pop(0) if self.failures else None + rpc_error = self.rpc_errors.pop(0) if self.rpc_errors else None + if auth != "Bearer " + self.token: + return self._reply(h, 401, b'{"error": "unauthorized"}') + if failure: + extra = {"Retry-After": str(failure[1])} if failure[1] is not None else {} + return self._reply(h, failure[0], b'{"error": "busy"}', extra) + try: + if u.path == "/api/" and verb == "POST": + body = json.loads(raw) + if rpc_error is not None: + out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error} + else: + out = {"jsonrpc": "2.2", "callid": body.get("callid"), + "result": self.rpc(body["method"], body.get("kwargs") or {})} + return self._reply(h, 200, json.dumps(out).encode("utf-8")) + if u.path.startswith("/files/") and verb == "GET": + if u.path not in self.files: + return self._reply(h, 404, b'{"error": "not found"}') + data, with_length = self.files[u.path] + h.send_response(200) + h.send_header("Content-Type", "application/octet-stream") + if with_length: + h.send_header("Content-Length", str(len(data))) + h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения + h.wfile.write(data) + return + self._reply(h, 404, b"{}") + except (BrokenPipeError, ConnectionResetError): + pass + + def _reply(self, h, status, body, headers=None): + h.send_response(status) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(body))) + for k, v in (headers or {}).items(): + h.send_header(k, v) + h.end_headers() + h.wfile.write(body) + + def rpc(self, method, kw): + found = [d for d in self.docs if self._match(d, kw.get("filter") or [])] + fields = kw.get("fields") + pick = (lambda d: {k: d.get(k) for k in fields}) if fields else dict # noqa: E731 + if method == "CmfDocument.get": + return pick(found[0]) if found else None + if method == "CmfDocument.list": + lo, hi = kw.get("slice") or [0, len(found)] + return [pick(d) for d in found[lo:hi]] + raise ValueError("метод %s не поддержан" % method) + + @staticmethod + def _match(doc, flt): + for field, op, value in flt: + if op != "==" or _field(doc, field) != value: + return False + return True diff --git a/tests/test_clients.py b/tests/test_clients.py index 79011ec..1d07005 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -61,7 +61,7 @@ class ClientsTest(unittest.TestCase): def test_get_and_counters(self): self.srv.routes["/x"] = (200, {"Content-Type": "application/json"}, b'{"a": 1}') - self.assertEqual(self.eva.call("GET", "/x"), {"a": 1}) + self.assertEqual(agent.Http.call(self.eva, "GET", "/x"), {"a": 1}) self.assertEqual(self.eva.calls, 1) self.assertIsNotNone(self.eva.last_call_at) self.assertIsNotNone(self.eva.last_call_iso) @@ -72,21 +72,21 @@ class ClientsTest(unittest.TestCase): self.assertEqual(self.eva.headers["Authorization"], "Bearer eva-secret") self.assertNotIn("eva-secret", repr(self.eva)) self.srv.routes["/x"] = (200, {}, b"{}") - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret") def test_secret_not_in_log_output(self): buf = io.StringIO() self.srv.routes["/x"] = (200, {}, b"{}") with contextlib.redirect_stdout(buf): - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") agent.log("проверка") self.assertNotIn("eva-secret", buf.getvalue()) def test_http_error_carries_code_text_headers(self): self.srv.routes["/x"] = (429, {"Retry-After": "7"}, b"slow down") with self.assertRaises(agent.HttpError) as cm: - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") self.assertEqual(cm.exception.code, 429) self.assertEqual(cm.exception.headers.get("retry-after"), "7") self.assertIn("slow down", cm.exception.text) @@ -128,14 +128,14 @@ class ClientsTest(unittest.TestCase): self.srv.routes["/a"] = (302, {"Location": "http://localhost:%d/b" % port}, b"") self.srv.routes["/b"] = (200, {}, b"{}") with self.assertRaises(agent.HttpError) as e: - self.eva.call("GET", "/a") + agent.Http.call(self.eva, "GET", "/a") self.assertIn("localhost", e.exception.text) self.assertNotIn("/b", self.srv.requests) def test_json_call_redirect_same_host_followed(self): self.srv.routes["/a"] = (302, {"Location": self.srv.base_url + "/b"}, b"") self.srv.routes["/b"] = (200, {}, b'{"ok": true}') - self.assertEqual(self.eva.call("GET", "/a"), {"ok": True}) + self.assertEqual(agent.Http.call(self.eva, "GET", "/a"), {"ok": True}) def test_error_body_read_failure_is_connection_error(self): import urllib.error @@ -147,14 +147,14 @@ class ClientsTest(unittest.TestCase): err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) with mock.patch("urllib.request.OpenerDirector.open", side_effect=err): with self.assertRaises(ConnectionError): - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") self.assertEqual(self.eva.calls, 1) def test_incomplete_read_is_connection_error(self): import http.client with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)): with self.assertRaises(ConnectionError): - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") def test_incomplete_read_inside_response_body(self): srv = socket.socket() @@ -172,19 +172,19 @@ class ClientsTest(unittest.TestCase): client = agent.EvaClient(eva_cfg("http://127.0.0.1:%d" % srv.getsockname()[1])) try: with self.assertRaises(ConnectionError): - client.call("GET", "/x") + agent.Http.call(client, "GET", "/x") finally: t.join(2) srv.close() def test_unreachable_raises_connection_error(self): with self.assertRaises(ConnectionError): - agent.EvaClient(eva_cfg("http://127.0.0.1:1")).call("GET", "/x") + agent.Http.call(agent.EvaClient(eva_cfg("http://127.0.0.1:1")), "GET", "/x") def test_non_json_200_becomes_http_error(self): self.srv.routes["/x"] = (200, {}, b"\xff\xfe not json") with self.assertRaises(agent.HttpError) as cm: - self.eva.call("GET", "/x") + agent.Http.call(self.eva, "GET", "/x") self.assertEqual(cm.exception.code, 200) self.assertEqual(self.eva.calls, 1) diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py new file mode 100644 index 0000000..292c1ce --- /dev/null +++ b/tests/test_eva_client.py @@ -0,0 +1,150 @@ +import io +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from tests import _common as c +from tests.fake_eva import FakeEva + +agent = c.agent + + +class EvaClientTest(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.addCleanup(self.eva.stop) + self.cli = agent.EvaClient(c.eva_cfg(self.eva.base_url, timeout=10)) + self.sleeps = [] + self.cli.sleep = self.sleeps.append + + def test_call_ok(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + doc = self.cli.get_doc("DOC-1", ["code", "name"]) + self.assertEqual(doc, {"code": "DOC-1", "name": "Корень"}) + self.assertIsNone(self.cli.get_doc("DOC-404", ["code"])) + req = self.eva.api_calls[0] + self.assertEqual(req["path"], "/api/?m=CmfDocument.get") + self.assertEqual(req["auth"], "Bearer eva-secret") + self.assertEqual(req["body"]["jsonrpc"], "2.2") + self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOC-1"]]) + self.assertEqual(self.cli.calls, 2) + + def test_retry_429_retry_after(self): + self.eva.fail_next(429, 1, retry_after=7) + self.assertEqual(self.cli.call("CmfDocument.list", filter=[], fields=["code"]), []) + self.assertEqual(self.sleeps, [7]) + self.assertEqual(self.cli.rate_limited, 1) + + def test_retry_after_capped(self): + self.eva.fail_next(429, 1, retry_after=600) + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(self.sleeps, [60]) + + def test_retry_503_then_ok(self): + self.eva.fail_next(503, 2) + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(self.sleeps, [1, 2]) + self.assertEqual(self.cli.rate_limited, 0) + + def test_gives_up_after_4(self): + self.eva.fail_next(503, 10) + with self.assertRaises(agent.HttpError) as cm: + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(cm.exception.code, 503) + self.assertEqual(len(self.eva.api_calls), 4) + self.assertEqual(self.sleeps, [1, 2, 4]) + + def test_no_retry_on_401(self): + self.cli.headers["Authorization"] = "Bearer wrong" + with self.assertRaises(agent.HttpError): + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(len(self.eva.api_calls), 1) + + def test_rpc_error(self): + self.eva.rpc_error_next("нет прав") + with self.assertRaises(agent.EvaError) as cm: + self.cli.call("CmfDocument.get", filter=[], fields=["code"]) + self.assertEqual(str(cm.exception), "CmfDocument.get: нет прав") + + def test_list_pages_200(self): + for i in range(450): + self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") + docs = self.cli.list_docs([["project.code", "==", "P"]], ["code"]) + self.assertEqual(len(docs), 450) + self.assertEqual(len(self.eva.api_calls), 3) + self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], + [[0, 200], [200, 400], [400, 600]]) + + def test_list_unwraps_dict(self): + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: {"items": orig(m, kw)} + self.eva.add_doc(1, "DOC-1") + self.assertEqual(self.cli.list_docs([], ["code"]), [{"code": "DOC-1"}]) + + def test_tree_parent_filter(self): + self.eva.add_doc(1, "DOC-1") + self.eva.add_doc(2, "DOC-2", tree_parent="DOC-1") + self.eva.add_doc(3, "DOC-3", tree_parent="DOC-2") + got = self.cli.list_docs([["tree_parent.code", "==", "DOC-1"]], ["code"]) + self.assertEqual(got, [{"code": "DOC-2"}]) + + def test_pacing_between_calls(self): + self.cli.pacing = 0.5 + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(self.sleeps, []) # перед первым вызовом паузы нет + self.cli.call("CmfDocument.list", filter=[], fields=["code"]) + self.assertEqual(len(self.sleeps), 1) + self.assertTrue(0 < self.sleeps[0] <= 0.5) + + def test_download_ok(self): + self.eva.add_file("/files/a.bin", b"abc") + self.assertEqual(self.cli.download(self.eva.base_url + "/files/a.bin", 10), b"abc") + self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret") + + def test_download_foreign_host(self): + with self.assertRaises(ValueError): + self.cli.download("http://example.invalid/files/a.bin", 10) + self.assertEqual(self.eva.requests, []) + + def test_download_limit(self): + limit = 10 * 1024 * 1024 + self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False) + self.eva.add_file("/files/ok.bin", b"x" * limit, length=False) + with self.assertRaises(agent.TooBig): + self.cli.download(self.eva.base_url + "/files/big.bin", limit) + self.assertEqual(len(self.cli.download(self.eva.base_url + "/files/ok.bin", limit)), limit) + + def test_download_404(self): + with self.assertRaises(agent.HttpError) as cm: + self.cli.download(self.eva.base_url + "/files/none", 10) + self.assertEqual(cm.exception.code, 404) + + def test_token_not_logged(self): + out, err = io.StringIO(), io.StringIO() + texts = [] + with redirect_stdout(out), redirect_stderr(err): + self.eva.fail_next(503, 8) + for call in (lambda: self.cli.call("CmfDocument.list", filter=[], fields=["code"]), + lambda: self.cli.download(self.eva.base_url + "/files/none", 10)): + try: + call() + except Exception as e: # noqa: BLE001 + texts.append(str(e)) + self.eva.rpc_error_next("сбой eva-secret") + try: + self.cli.call("CmfDocument.get", filter=[], fields=["code"]) + except agent.EvaError as e: + texts.append(str(e)) + dead = agent.EvaClient(c.eva_cfg("http://127.0.0.1:1")) + dead.sleep = lambda s: None + try: + dead.call("CmfDocument.get", filter=[], fields=["code"]) + except ConnectionError as e: + texts.append(str(e)) + self.assertEqual(len(texts), 4) + for t in texts + [out.getvalue(), err.getvalue()]: + self.assertNotIn("eva-secret", t) + self.assertNotIn("eva-secret", repr(self.cli)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index cffccf1..7a091a9 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -350,6 +350,7 @@ class RunnerTest(unittest.TestCase): self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd)) self.runner.loop() self.assertEqual(seen, [2]) + self.assertEqual(self.runner.eva.pacing, 0.5) def test_hub_down_at_start_keeps_polling(self): calls = [] -- 2.47.2 From 243b4b8f8d55177cce38d04479816cae8c4123b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:54:39 +0400 Subject: [PATCH 05/15] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=20Eva=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 9 +++ src/agent.py | 66 +++++++++++++++++++ tests/fixtures/pages/amp.html | 1 + tests/fixtures/pages/image.html | 1 + tests/fixtures/pages/links.html | 3 + tests/fixtures/pages/name_quoted.html | 1 + tests/fixtures/pages/thumb_and_link.html | 2 + tests/test_links.py | 83 ++++++++++++++++++++++++ tests/test_py39.py | 2 +- 9 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/pages/amp.html create mode 100644 tests/fixtures/pages/image.html create mode 100644 tests/fixtures/pages/links.html create mode 100644 tests/fixtures/pages/name_quoted.html create mode 100644 tests/fixtures/pages/thumb_and_link.html create mode 100644 tests/test_links.py diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 9dc2283..c17844f 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -153,3 +153,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. 24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.** Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. +25. **`classify_links`: уточнения разбора (задача 3).** + - Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется. + - Имя: `alt`/`title` → текст `` (пробелы схлопнуты) → `unquote` последнего сегмента пути. + - Порядок записей — порядок открывающих тегов (для `` сначала ссылка, потом картинка); повторы не склеиваются. + - Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода. + - `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет). + - В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`). + + Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации. diff --git a/src/agent.py b/src/agent.py index 7e8df37..e77c3dd 100644 --- a/src/agent.py +++ b/src/agent.py @@ -30,6 +30,7 @@ import urllib.error import urllib.parse import urllib.request import uuid +from html.parser import HTMLParser from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple @@ -325,6 +326,71 @@ class EvaClient(Http): return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) +class _LinkParser(HTMLParser): + """Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки).""" + + def __init__(self): + HTMLParser.__init__(self, convert_charrefs=True) + self.items = [] # [tag, ref, hint, text] + self._open = None # запись открытой , в неё копится текст + + def handle_starttag(self, tag, attrs): + a = dict(attrs) + if tag == "img": + ref = a.get("src") + if ref: + self.items.append(["img", ref, a.get("alt") or a.get("title") or "", ""]) + elif tag == "a": + ref = a.get("href") + self._open = None + if ref: + self._open = ["a", ref, a.get("title") or "", ""] + self.items.append(self._open) + + def handle_startendtag(self, tag, attrs): + if tag == "img": + self.handle_starttag(tag, attrs) + + def handle_endtag(self, tag): + if tag == "a": + self._open = None + + def handle_data(self, data): + if self._open is not None: + self._open[3] += data + + +_PAGE_CODE = re.compile(r"DOC-\d+", re.IGNORECASE) +_THUMB_TAIL = ".meta/thumbnail.jpg" + + +def classify_links(html: str, base_url: str) -> Tuple[list, list]: + """Вложения и ссылки на страницы из HTML страницы Eva (design 6–7). Порядок появления, повторы не склеиваются. + + ref — значение атрибута после unescape (design 12). Чужой хост и static/ не передаются.""" + parser = _LinkParser() + parser.feed(html or "") + parser.close() + base = urllib.parse.urlsplit(base_url) + attachments, links = [], [] + for _tag, ref, hint, text in parser.items: + parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip())) + if (parts.scheme, parts.netloc) != (base.scheme, base.netloc): + continue + path = parts.path + if path.startswith("/files/"): + if path.endswith(_THUMB_TAIL): + path = path[:-len(_THUMB_TAIL)] + name = " ".join(hint.split()) or " ".join(text.split()) or urllib.parse.unquote(path.rsplit("/", 1)[-1]) + url = urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")) + attachments.append({"ref": ref, "url": url, "name": name}) + continue + m = _PAGE_CODE.search(path) + if m: + links.append({"ref": ref, "code": m.group(0).upper()}) + return attachments, links + + class FolderTransport: """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). Запрос — файл req/.json, ответ кладёт реле хаба в resp/.json. Ошибки те же, что у Http.call: diff --git a/tests/fixtures/pages/amp.html b/tests/fixtures/pages/amp.html new file mode 100644 index 0000000..b582d73 --- /dev/null +++ b/tests/fixtures/pages/amp.html @@ -0,0 +1 @@ +к diff --git a/tests/fixtures/pages/image.html b/tests/fixtures/pages/image.html new file mode 100644 index 0000000..0fabd84 --- /dev/null +++ b/tests/fixtures/pages/image.html @@ -0,0 +1 @@ +

Схема:

Схема diff --git a/tests/fixtures/pages/links.html b/tests/fixtures/pages/links.html new file mode 100644 index 0000000..45abe7c --- /dev/null +++ b/tests/fixtures/pages/links.html @@ -0,0 +1,3 @@ +

см. раздел абс. +чужой файл чужая +прочее

diff --git a/tests/fixtures/pages/name_quoted.html b/tests/fixtures/pages/name_quoted.html new file mode 100644 index 0000000..fffc285 --- /dev/null +++ b/tests/fixtures/pages/name_quoted.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/pages/thumb_and_link.html b/tests/fixtures/pages/thumb_and_link.html new file mode 100644 index 0000000..a4521cb --- /dev/null +++ b/tests/fixtures/pages/thumb_and_link.html @@ -0,0 +1,2 @@ +

model.xlsx model.xlsx

+

diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..fd9b7cd --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,83 @@ +"""Разбор ссылок страницы Eva: вложения и ссылки на страницы (design 6–7, 12).""" +import os +import unittest + +from tests import _common # noqa: F401 +import agent + +BASE = "https://eva.test" +OBJ = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def page(name): + with open(os.path.join(HERE, "fixtures", "pages", name), encoding="utf-8") as fh: + return fh.read() + + +class ClassifyLinksTest(unittest.TestCase): + def test_image_relative_files(self): + att, links = agent.classify_links(page("image.html"), BASE) + self.assertEqual(att, [{"ref": OBJ + "/img.png?version=1", "url": BASE + OBJ + "/img.png", "name": "Схема"}]) + self.assertEqual(links, []) + + def test_thumbnail_maps_to_file(self): + att, _ = agent.classify_links(page("thumb_and_link.html"), BASE) + self.assertEqual(att[0]["url"], BASE + OBJ + "/model.xlsx") + self.assertEqual(att[0]["name"], "model.xlsx") + self.assertEqual(att[0]["ref"], OBJ + "/model.xlsx.meta/thumbnail.jpg?version=1") + self.assertEqual(att[2]["url"], BASE + OBJ + "/data.dbml") + self.assertEqual(att[2]["name"], "data.dbml") + + def test_file_link_same_url(self): + att, _ = agent.classify_links(page("thumb_and_link.html"), BASE) + self.assertEqual(len(att), 3) # повторы не склеиваются + self.assertEqual(att[1]["url"], att[0]["url"]) + self.assertEqual(att[1]["ref"], OBJ + "/model.xlsx?version=1") + self.assertEqual(att[1]["name"], "model.xlsx") + + def test_static_ignored(self): + att, links = agent.classify_links('', BASE) + self.assertEqual((att, links), ([], [])) + + def test_page_link_relative(self): + _, links = agent.classify_links(page("links.html"), BASE) + self.assertEqual(links[0], {"ref": "project/Document/DOC-019834", "code": "DOC-019834"}) + self.assertEqual(links[1], {"ref": "https://eva.test/project/Document/doc-5", "code": "DOC-5"}) + + def test_foreign_host(self): + att, links = agent.classify_links(page("links.html"), BASE) + self.assertEqual(att, []) + self.assertEqual([l["code"] for l in links], ["DOC-019834", "DOC-5"]) + att, links = agent.classify_links( + 'xy', + BASE) + self.assertEqual((att, links), ([], [])) + + def test_name_unquoted(self): + att, _ = agent.classify_links(page("name_quoted.html"), BASE) + self.assertEqual(att[0]["name"], "ПМП.xlsx") + self.assertEqual(att[0]["url"], BASE + OBJ + "/%D0%9F%D0%9C%D0%9F.xlsx") + + def test_ref_exact(self): + att, links = agent.classify_links(page("amp.html"), BASE) + self.assertEqual(att[0]["ref"], OBJ + "/a.png?version=1&x=2") # после unescape, как ищет хаб + self.assertEqual(links[0]["ref"], "project/Document/DOC-7?a=1&b=2") + self.assertEqual(att[0]["url"], BASE + OBJ + "/a.png") + + def test_base_with_trailing_slash_and_title(self): + att, _ = agent.classify_links('текст' % OBJ, BASE + "/") + self.assertEqual(att[0]["name"], "Файл") + + def test_name_from_link_text(self): + att, _ = agent.classify_links(' мой файл ' % OBJ, BASE) + self.assertEqual(att[0]["name"], "мой файл") + + def test_order_nested_img_in_link(self): + att, _ = agent.classify_links( + '' % (OBJ, OBJ, OBJ), BASE) + self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_py39.py b/tests/test_py39.py index 03c76c9..0f95115 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -18,7 +18,7 @@ class Py39Test(unittest.TestCase): ast.parse(f.read(), feature_version=(3, 9)) def test_no_third_party_imports(self): - allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "subprocess"} + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "html", "subprocess"} for name in self.FILES: with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f: tree = ast.parse(f.read()) -- 2.47.2 From 9793fdd40b8c3ce50c779927ce810e0fe4e238c0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:03:20 +0400 Subject: [PATCH 06/15] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20wiki=5Fsync=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 15 ++ src/agent.py | 106 +++++++++++- tests/fake_hub.py | 53 ++++++ tests/test_folder.py | 18 +- tests/test_wiki_sync.py | 199 +++++++++++++++++++++++ 5 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 tests/test_wiki_sync.py diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index c17844f..5bb26d3 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -162,3 +162,18 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`). Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации. + +26. **Обход дерева `wiki_sync` ведётся по коду страницы, а не по `id` (уточнение решения 5).** + - `children`, `seen` и `parent_code` ключуются кодом (`ref.code`): код уникален и есть в каждой ссылке `parent`/`tree_parent`; `id` берётся только для `eva_id`. + - Соседи сортируются по `(orderno is None, orderno, name без регистра, code)` — как `collect_subtree` экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма. + - Дерево с проектом — один список документов проекта; без проекта — обход в ширину через `tree_parent.code` (защита от циклов тоже по коду). + + Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт `code`; поле `id` в ссылке может не совпасть с `id` документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся. +27. **Страница без названия получает `title = code`; корень без названия — `root_title = code`.** + Обоснование: `stage._check` хаба отклоняет пустой `title` (422 `<код>: title`), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени. +28. **Страница, исчезнувшая между списком и чтением (`get_doc` → `None`), — `failed` «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается.** + Обоснование: отчёт `codes` обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация. +29. **Размер пачки считается тем же кодированием, что у `Http.call` (`ensure_ascii=False`, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ).** + Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ. +30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде. + Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e. diff --git a/src/agent.py b/src/agent.py index e77c3dd..2ad72f4 100644 --- a/src/agent.py +++ b/src/agent.py @@ -663,6 +663,71 @@ def _parse_at(value) -> float: return dt.timestamp() +# --------------------------------------------------------------------------- +# Дерево страниц Eva +# --------------------------------------------------------------------------- +MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES) +MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ +TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"] + + +def _ref_code(ref) -> Optional[str]: + """Код из ссылки Eva {"id", "code"}; страница без родителя — None.""" + code = ref.get("code") if isinstance(ref, dict) else None + return code if isinstance(code, str) and code else None + + +def _orderno(value) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, float) and value.is_integer(): + return int(value) + return value if isinstance(value, int) else None + + +def build_children(eva: "EvaClient", root: dict) -> Dict[str, List[dict]]: + """{код родителя: [дети]} для поддерева корня. С проектом — один список документов проекта и связи parent/tree_parent + (tree_parent в Eva бывает не заполнен); без проекта — обход в ширину через фильтр tree_parent.code.""" + children = {} # type: Dict[str, List[dict]] + project = _ref_code(root.get("project")) + if project: + for d in eva.list_docs([["project.code", "==", project]], TREE_FIELDS): + for pc in {_ref_code(d.get("parent")), _ref_code(d.get("tree_parent"))} - {None}: + children.setdefault(pc, []).append(d) + else: + queue, seen = [root], {root.get("code")} + while queue: + node = queue.pop(0) + kids = eva.list_docs([["tree_parent.code", "==", node["code"]]], TREE_FIELDS) + children[node["code"]] = kids + for k in kids: + if k.get("code") not in seen: + seen.add(k.get("code")) + queue.append(k) + for pc, kids in children.items(): + uniq = {k["code"]: k for k in kids if k.get("code")} + children[pc] = sorted(uniq.values(), key=lambda k: (_orderno(k.get("orderno")) is None, + _orderno(k.get("orderno")) or 0, + (k.get("name") or "").lower(), k["code"])) + return children + + +def walk(root: dict, children: Dict[str, List[dict]]): + """Обход в глубину, корень первым: (документ, код родителя по обходу). Каждый код — один раз (циклы, два родителя).""" + seen = set() # type: set + stack = [(root, None)] + while stack: + doc, parent = stack.pop() + code = doc["code"] + if code in seen: + continue + seen.add(code) + yield doc, parent + for kid in reversed(children.get(code, [])): + if kid["code"] not in seen: + stack.append((kid, code)) + + # --------------------------------------------------------------------------- # Исполнитель: команда wiki_sync / wiki_fetch (читает Eva, отдаёт хабу) # --------------------------------------------------------------------------- @@ -713,7 +778,44 @@ class Executor: raise HubError(e) from None def do_wiki_sync(self, p: dict, cmd: dict) -> dict: - return self._out("failed", error="wiki_sync: не реализовано") + """Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается, + поэтому после перезапуска команда выполняется заново целиком (design 9).""" + root_code = str(p.get("root") or "").strip() + if not root_code: + return self._out("failed", error="wiki_sync: не задан root") + root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"]) + if not root: + return self._out("failed", error="страница %s не найдена или нет прав" % root_code) + children = build_children(self.eva, root) + root_code = root.get("code") or root_code + base = self.eva.base + codes = [] # type: List[str] + batch = [] # type: list + size = 0 + for doc, parent_code in walk(root, children): + code = doc["code"] + full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"]) + if not full: + return self._out("failed", error="страница %s не найдена или нет прав" % code) + html = full.get("text") if isinstance(full.get("text"), str) else "" + attachments, links = classify_links(html, base) + page = {"code": code, "eva_id": str(doc["id"]) if doc.get("id") is not None else None, + # хаб отклоняет пустой title (stage._check): без названия страница называется своим кодом + "title": (full.get("name") or doc.get("name") or "").strip() or code, + "parent_code": parent_code, "orderno": _orderno(doc.get("orderno")), + "url": urllib.parse.urljoin(base + "/", "project/Document/" + code), + "html": html, "attachments": attachments, "links": links} + psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2 + if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES): + self._hub(self.hub.wiki_pages, cmd["id"], batch) + batch, size = [], 0 + batch.append(page) + size += psize + codes.append(code) + if batch: + self._hub(self.hub.wiki_pages, cmd["id"], batch) + title = (root.get("name") or "").strip() or root_code + return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: return self._out("failed", error="wiki_fetch: не реализовано") @@ -1001,7 +1103,7 @@ class Runner: return # остановка: finished не пишем, команду подхватит recover out["jira_calls"], out["rate_limited"] = calls, limited self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited) - log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) + log(" → %s %s" % (out["status"], (out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))[:300])) rec = dict(out) rec["command_id"] = cid self.send_result(rec) diff --git a/tests/fake_hub.py b/tests/fake_hub.py index eabe8f8..d8337c1 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -9,6 +9,55 @@ import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +MAX_PAGES = 50 + + +def check_pages(command, command_id, pages): + """Форма пачки как в stage.accept/_check хаба: (код, тело ошибки) или None. 422 — {"error", "detail"}.""" + def bad(detail, status=422, error="bad_pages"): + return status, {"error": error, "detail": detail} + + if isinstance(command_id, bool) or not isinstance(command_id, int): + return bad("command_id — целое", error="bad_request") + if not isinstance(pages, list) or not pages or len(pages) > MAX_PAGES: + return bad("pages — список от 1 до %d страниц" % MAX_PAGES) + if command is None: + return bad("command %s" % command_id, 404, "not_found") + if command["type"] != "wiki_sync" or command["status"] != "claimed": + return bad("команда %s не выдана агенту как wiki_sync" % command_id, 409, "not_claimed") + limits = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200} + for i, page in enumerate(pages, 1): + if not isinstance(page, dict): + return bad("страница %d: не объект" % i) + code = page.get("code") + if not isinstance(code, str) or not code.strip() or len(code) > limits["code"]: + return bad("страница %d: code" % i) + for key, required in (("title", True), ("parent_code", False), ("url", False)): + v = page.get(key) + if v is None and not required: + continue + if not isinstance(v, str) or (required and not v.strip()) or len(v) > limits[key]: + return bad("%s: %s" % (code, key)) + if not isinstance(page.get("html"), str): + return bad("%s: html" % code) + o = page.get("orderno") + if o is not None and (not isinstance(o, int) or isinstance(o, bool)): + return bad("%s: orderno" % code) + e = page.get("eva_id") + if e is not None and (not isinstance(e, str) or len(e) > limits["eva_id"]): + return bad("%s: eva_id" % code) + att, links = page.get("attachments") or [], page.get("links") or [] + if not isinstance(att, 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 att): + return bad("%s: attachments" % code) + 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): + return bad("%s: links" % code) + return None + + class FakeHub: token = "agent-token" @@ -126,6 +175,10 @@ class FakeHub: if path == "/agent/wiki/pages" and method == "POST": if self.reject_pages: return self.reject_pages + cid = body.get("command_id") + bad = check_pages(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("pages")) + if bad: + return bad self.pages.append(body) return 200, {"accepted": len(body.get("pages") or [])} if path == "/agent/files" and method == "POST": diff --git a/tests/test_folder.py b/tests/test_folder.py index 0e3ff9e..42f46ee 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -138,18 +138,26 @@ class FolderTransportTest(unittest.TestCase): self.assertIsNone(hub.folder) self.assertEqual(hub.settings()["settings_version"], 1) + def claimed_sync(self): + cid = self.hub.enqueue("wiki_sync", {"label": "x", "root": "DOC-1"}) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + return cid + def test_wiki_pages_through_folder(self): self.run_relay() - pages = [{"code": "DOC-1", "name": "Корень", "html": "

" + "я" * 200000 + "

"}] - got = self.client().wiki_pages(5, pages) + cid = self.claimed_sync() + pages = [{"code": "DOC-1", "title": "Корень", "html": "

" + "я" * 200000 + "

"}] + got = self.client().wiki_pages(cid, pages) self.assertEqual(got, {"accepted": 1}) - self.assertEqual(self.hub.pages, [{"command_id": 5, "pages": pages}]) + self.assertEqual(self.hub.pages, [{"command_id": cid, "pages": pages}]) self.assertEqual(self.relay.requests[0]["path"], "/agent/wiki/pages") def test_wiki_pages_over_http(self): hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}) - self.assertEqual(hub.wiki_pages(5, [{"code": "DOC-1"}]), {"accepted": 1}) - self.assertEqual(self.hub.pages[0]["command_id"], 5) + cid = self.claimed_sync() + self.assertEqual(hub.wiki_pages(cid, [{"code": "DOC-1", "title": "T", "html": ""}]), {"accepted": 1}) + self.assertEqual(self.hub.pages[0]["command_id"], cid) def test_wiki_pages_rejected_is_http_error(self): self.hub.reject_pages = (422, {"error": "bad_page"}) diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py new file mode 100644 index 0000000..3a0a086 --- /dev/null +++ b/tests/test_wiki_sync.py @@ -0,0 +1,199 @@ +import json +import os +import tempfile +import unittest + +from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_eva import FakeEva +from fake_hub import FakeHub + + +class SyncBase(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.hub = FakeHub(SETTINGS) + self.addCleanup(self.eva.stop) + self.addCleanup(self.hub.stop) + self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.client = agent.EvaClient(self.cfg) + self.client.sleep = no_sleep + self.ex = agent.Executor(self.client, agent.HubClient(self.cfg), dict(SETTINGS)) + + def sync(self, root="DOC-1", label="tz"): + cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root}) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + self.cid = cid + return self.ex.run({"id": cid, "type": "wiki_sync", "params": {"label": label, "root": root}}) + + def sent(self): + return [p for body in self.hub.pages for p in body["pages"]] + + +class WikiSyncTest(SyncBase): + def test_sync_tree_order(self): + e = self.eva + e.add_doc(1, "DOC-1", "Корень", project="P", text="

корень

") + e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P", orderno=2, text="

б

") + e.add_doc(3, "DOC-3", "А", parent="DOC-1", project="P", orderno=1, text="

а

") + e.add_doc(4, "DOC-4", "Внук", parent="DOC-3", project="P", text="

внук

") + e.add_doc(5, "DOC-5", "Чужой", parent="DOC-1", project="OTHER") + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"]) + self.assertEqual(out["result"]["root_title"], "Корень") + self.assertEqual(out["result"]["eva_calls"], self.client.calls) + pages = {p["code"]: p for p in self.sent()} + self.assertEqual([p["code"] for p in self.sent()], out["result"]["codes"]) + self.assertIsNone(pages["DOC-1"]["parent_code"]) + self.assertEqual(pages["DOC-3"]["parent_code"], "DOC-1") + self.assertEqual(pages["DOC-4"]["parent_code"], "DOC-3") + self.assertEqual(pages["DOC-3"]["orderno"], 1) + self.assertIsNone(pages["DOC-4"]["orderno"]) + self.assertEqual(pages["DOC-3"]["url"], self.eva.base_url + "/project/Document/DOC-3") + self.assertEqual(pages["DOC-3"]["eva_id"], "3") + self.assertEqual(pages["DOC-3"]["title"], "А") + self.assertEqual(pages["DOC-3"]["html"], "

а

") + self.assertEqual(self.hub.pages[0]["command_id"], self.cid) + + def test_sync_passes_links_and_attachments(self): + self.eva.add_doc(1, "DOC-1", "К", project="P", text=( + '

xСхема

')) + out = self.sync() + self.assertEqual(out["status"], "done", out) + page = self.sent()[0] + self.assertEqual(page["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}]) + self.assertEqual(page["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png", + "name": "Схема"}]) + + def test_sync_batches_50(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + for i in range(2, 121): + self.eva.add_doc(i, "DOC-%d" % i, "С%03d" % i, parent="DOC-1", project="P", orderno=i) + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertEqual([len(b["pages"]) for b in self.hub.pages], [50, 50, 20]) + self.assertEqual(len(out["result"]["codes"]), 120) + self.assertEqual(out["result"]["codes"][0], "DOC-1") + + def test_sync_batch_bytes(self): + big = "

" + "x" * (1500 * 1024) + "

" + self.eva.add_doc(1, "DOC-1", "Корень", project="P", text=big) + for i in (2, 3): + self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i, text=big) + out = self.sync() + self.assertEqual(out["status"], "done", out) + sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.pages] + self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes) + self.assertEqual([len(b["pages"]) for b in self.hub.pages], [2, 1]) + + def test_tree_cycle_and_two_parents(self): + e = self.eva + e.add_doc(1, "DOC-1", "Корень", project="P") + e.add_doc(3, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1) + e.add_doc(10, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2) + e.add_doc(11, "DOC-B", "Б", tree_parent="DOC-A", project="P") + e.add_doc(12, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3) + out = self.sync() + self.assertEqual(out["status"], "done", out) + codes = out["result"]["codes"] + self.assertEqual(len(codes), len(set(codes))) + self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"]) + self.assertEqual([p["code"] for p in self.sent()], codes) + + def test_root_without_project(self): + e = self.eva + e.add_doc(1, "DOC-1", "Корень", text="

к

") + e.add_doc(2, "DOC-2", "Б", tree_parent="DOC-1", orderno=2) + e.add_doc(3, "DOC-3", "А", tree_parent="DOC-1", orderno=1) + e.add_doc(4, "DOC-4", "Внук", tree_parent="DOC-3") + e.add_doc(5, "DOC-5", "Посторонний", tree_parent="DOC-9") + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"]) + self.assertEqual({p["code"]: p["parent_code"] for p in self.sent()}, + {"DOC-1": None, "DOC-3": "DOC-1", "DOC-4": "DOC-3", "DOC-2": "DOC-1"}) + + def test_root_missing(self): + out = self.sync("DOC-9") + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "страница DOC-9 не найдена или нет прав") + self.assertEqual(self.hub.pages, []) + + def test_eva_failure_reported(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.eva.fail_next(503, 4) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("503", out["error"]) + self.assertNotIn(FakeEva.token, out["error"]) + self.assertEqual(self.hub.pages, []) + + def test_hub_rejects_batch(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.hub.reject_pages = (422, {"error": "bad_pages", "detail": "DOC-1: html"}) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("422", out["error"]) + self.assertIn("DOC-1: html", out["error"]) + + def test_empty_name_falls_back_to_code(self): + self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check) + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.sent()[0]["title"], "DOC-1") + self.assertEqual(out["result"]["root_title"], "DOC-1") + + def test_page_vanished(self): + e = self.eva + e.add_doc(1, "DOC-1", "Корень", project="P") + e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P") + real = self.client.get_doc + self.client.get_doc = lambda code, fields: None if code == "DOC-2" else real(code, fields) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("DOC-2", out["error"]) + + def test_counts(self): + e = self.eva + e.add_doc(1, "DOC-1", "Корень", project="P") + e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P") + e.fail_next(429, 2, retry_after=0) + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["jira_calls"], len(e.requests)) + self.assertEqual(out["jira_calls"], self.client.calls) + self.assertEqual(out["rate_limited"], 2) + + +class WikiSyncRecoverTest(unittest.TestCase): + def test_recover_resyncs(self): + eva, hub = FakeEva(), FakeHub(SETTINGS) + tmp = tempfile.TemporaryDirectory() + for x in (eva.stop, hub.stop, tmp.cleanup): + self.addCleanup(x) + eva.add_doc(1, "DOC-1", "Корень", project="P") + for i in range(2, 61): + eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i) + cid = hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1"}) + hub.queue.remove(cid) + hub.commands[cid]["status"] = "claimed" + hub.in_flight = cid + journal = agent.Journal(os.path.join(tmp.name, "journal.jsonl")) + journal.claimed(cid) + journal.started(cid) # перезапуск посреди выполнения: finished нет + cfg = eva_cfg(eva.base_url, hub_url=hub.base_url, hub_token=hub.token) + runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal) + runner.sleep = lambda s: None + runner.eva.sleep = no_sleep + runner.idle_limit = 1 + runner.loop() + self.assertEqual(hub.results[cid]["status"], "done", hub.results[cid]) + self.assertEqual(len(hub.results[cid]["result"]["codes"]), 60) + self.assertEqual([len(b["pages"]) for b in hub.pages], [50, 10]) + self.assertEqual(len([r for r in journal.records() if r["kind"] == "finished"]), 1) + self.assertEqual(journal.unfinished_started(), []) + + +if __name__ == "__main__": + unittest.main() -- 2.47.2 From 50878d5965294f7822f1abd096c9e48407c4bde4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:08:10 +0400 Subject: [PATCH 07/15] =?UTF-8?q?wiki=5Fsync:=20=D1=80=D0=B0=D0=B7=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=20parent/tree=5Fparent=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D1=83=20id=20->=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20(T4.1,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 7 ++++++ src/agent.py | 23 ++++++++++++++--- tests/fake_eva.py | 32 +++++++++++++++++++++--- tests/test_wiki_sync.py | 31 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 5bb26d3..5be37aa 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -177,3 +177,10 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ. 30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде. Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e. +31. **Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку `parent`/`tree_parent` в код разрешает `build_children`.** + - Ссылка Eva приходит то объектом `{id, code, ...}`, то строкой-id, то объектом без `code` (так говорит `ref_id` эталонного экспорта). + - `build_children` строит карту `str(id) → code` по документам проекта и корню. Ссылка с `code` даёт код сразу; иначе берётся `id` (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается. + - Без проекта (фильтр `tree_parent.code`) разрешение не нужно: родитель известен по запросу. + - `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`. + + Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена). diff --git a/src/agent.py b/src/agent.py index 2ad72f4..f465700 100644 --- a/src/agent.py +++ b/src/agent.py @@ -672,11 +672,21 @@ TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "ordern def _ref_code(ref) -> Optional[str]: - """Код из ссылки Eva {"id", "code"}; страница без родителя — None.""" + """Код из ссылки Eva с полем code; страница без родителя — None.""" code = ref.get("code") if isinstance(ref, dict) else None return code if isinstance(code, str) and code else None +def _ref_id(ref) -> Optional[str]: + """Ссылочные поля Eva приходят то объектом {id, code, ...}, то строкой-id (как в eva_wiki_export.ref_id).""" + if not ref: + return None + if isinstance(ref, dict): + v = ref.get("id") + return str(v) if v not in (None, "") else None + return str(ref) + + def _orderno(value) -> Optional[int]: if isinstance(value, bool): return None @@ -691,8 +701,15 @@ def build_children(eva: "EvaClient", root: dict) -> Dict[str, List[dict]]: children = {} # type: Dict[str, List[dict]] project = _ref_code(root.get("project")) if project: - for d in eva.list_docs([["project.code", "==", project]], TREE_FIELDS): - for pc in {_ref_code(d.get("parent")), _ref_code(d.get("tree_parent"))} - {None}: + docs = eva.list_docs([["project.code", "==", project]], TREE_FIELDS) + id_to_code = {str(d["id"]): d["code"] for d in docs + [root] if d.get("id") is not None and d.get("code")} + + def parent_code(ref) -> Optional[str]: + # dict с code — код как есть; иначе (dict только с id, голая строка-id) — через карту id → код + return _ref_code(ref) or id_to_code.get(_ref_id(ref) or "") + + for d in docs: + for pc in {parent_code(d.get("parent")), parent_code(d.get("tree_parent"))} - {None}: children.setdefault(pc, []).append(d) else: queue, seen = [root], {root.get("code")} diff --git a/tests/fake_eva.py b/tests/fake_eva.py index 28afa24..484eda1 100644 --- a/tests/fake_eva.py +++ b/tests/fake_eva.py @@ -6,7 +6,9 @@ from urllib.parse import parse_qs, urlsplit def _field(doc, path): - """Значение поля по пути `project.code`: ссылка хранится как {"id", "code"}.""" + """Значение поля по пути `project.code`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом.""" + if path in ("parent.code", "tree_parent.code"): + return doc.get(path.split(".")[0]) cur = doc for part in path.split("."): if not isinstance(cur, dict): @@ -20,6 +22,9 @@ class FakeEva: def __init__(self): self.docs = [] + # форма ссылок parent/tree_parent в ответах: "full" — {"id": "", "code"} (по умолчанию, как в Eva), + # "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка) + self.ref_form = "full" self.files = {} # путь → (байты, с Content-Length?) self.requests = [] # {"method", "path", "body", "auth"} self.failures = [] # [status, retry_after] — по одному на ответ @@ -49,11 +54,30 @@ class FakeEva: self.server.server_close() def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""): + # parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731 - self.docs.append({"id": id, "code": code, "name": name, "parent": ref(parent), - "tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno, + self.docs.append({"id": id, "code": code, "name": name, "parent": parent, + "tree_parent": tree_parent, "project": ref(project), "orderno": orderno, "text": text}) + def _ref(self, code): + if code is None: + return None + target = next((d for d in self.docs if d["code"] == code), None) + rid = str(target["id"]) if target else code + form = self.ref_form + if form == "id_only": + return {"id": rid} + if form == "string": + return rid + if form == "code_id": + return {"id": code, "code": code} + return {"id": rid, "code": code} + + def _out(self, d, fields): + d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"])) + return {k: d.get(k) for k in fields} if fields else d + def add_file(self, path, data, length=True): self.files[path] = (data, length) @@ -118,7 +142,7 @@ class FakeEva: def rpc(self, method, kw): found = [d for d in self.docs if self._match(d, kw.get("filter") or [])] fields = kw.get("fields") - pick = (lambda d: {k: d.get(k) for k in fields}) if fields else dict # noqa: E731 + pick = lambda d: self._out(d, fields) # noqa: E731 if method == "CmfDocument.get": return pick(found[0]) if found else None if method == "CmfDocument.list": diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 3a0a086..5ac9beb 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -101,6 +101,37 @@ class WikiSyncTest(SyncBase): self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"]) self.assertEqual([p["code"] for p in self.sent()], codes) + def fill_forms_tree(self): + e = self.eva + e.add_doc(101, "DOC-1", "Корень", project="P") + e.add_doc(103, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1) # только parent + e.add_doc(104, "DOC-4", "Внук", tree_parent="DOC-3", project="P") # только tree_parent + e.add_doc(110, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2) + e.add_doc(111, "DOC-B", "Б", tree_parent="DOC-A", project="P") # цикл A <-> B + e.add_doc(112, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3) # два родителя + + def test_all_ref_forms(self): + """Ссылки parent/tree_parent бывают {id, code}, {id} и голой строкой-id; id документа не равен коду.""" + for form in ("full", "id_only", "string", "code_id"): + with self.subTest(form=form): + self.setUp() + self.eva.ref_form = form + self.fill_forms_tree() + out = self.sync() + self.assertEqual(out["status"], "done", out) + codes = out["result"]["codes"] + self.assertEqual(codes[:4], ["DOC-1", "DOC-3", "DOC-C", "DOC-4"]) + self.assertEqual(len(codes), len(set(codes))) + self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-4", "DOC-A", "DOC-B", "DOC-C"]) + parents = {p["code"]: p["parent_code"] for p in self.sent()} + self.assertEqual(parents["DOC-4"], "DOC-3") + self.assertEqual(parents["DOC-A"], "DOC-1") + self.assertEqual(parents["DOC-B"], "DOC-A") + self.assertEqual(parents["DOC-C"], "DOC-3") # первый родитель по обходу в глубину + self.assertEqual(self.sent()[0]["eva_id"], "101") + self.eva.stop() + self.hub.stop() + def test_root_without_project(self): e = self.eva e.add_doc(1, "DOC-1", "Корень", text="

к

") -- 2.47.2 From 3597495a6a15c5e375e6dd4781ab0a3e2558fd43 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:17:49 +0400 Subject: [PATCH 08/15] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20wiki=5Ffetch=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 9 +++ src/agent.py | 29 +++++++- tests/fake_eva.py | 6 +- tests/fake_hub.py | 3 + tests/test_folder.py | 2 +- tests/test_runner.py | 2 +- tests/test_wiki_fetch.py | 93 ++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 tests/test_wiki_fetch.py diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 5be37aa..121ef5d 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -184,3 +184,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`. Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена). + +32. **`wiki_fetch` (задача 5): уточнения.** + - Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается. + - Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются). + - Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`. + - Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки. + - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). + + Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. diff --git a/src/agent.py b/src/agent.py index f465700..8c71d34 100644 --- a/src/agent.py +++ b/src/agent.py @@ -113,6 +113,9 @@ class RedirectAway(Exception): self.host = host +MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт) + + class TooBig(Exception): """Ответ длиннее предела: чтение оборвано.""" @@ -201,11 +204,13 @@ class Http: req.add_header("Accept", "*/*") try: with self._opener().open(req, timeout=timeout or self.timeout) as resp: + if resp.length is not None and resp.length > limit: + raise TooBig("ответ длиннее %d байт (Content-Length %d)" % (limit, resp.length)) # тело не читаем data = resp.read(limit + 1) rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше if rest and len(data) <= limit: raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest)) - except RedirectAway: + except (RedirectAway, TooBig): self._touch() raise except urllib.error.HTTPError as e: @@ -835,7 +840,27 @@ class Executor: return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: - return self._out("failed", error="wiki_fetch: не реализовано") + """Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24); + содержимое в лог не попадает, в памяти одна копия (плюс base64 на время отправки).""" + url = str(p.get("url") or "").strip() + if not url: + return self._out("failed", error="wiki_fetch: не задан url") + try: + data = self.eva.download(url, MAX_ATTACHMENT) + except ValueError: + return self._out("failed", error="чужой хост") + except TooBig: + return self._out("failed", error="файл больше 10 МБ — откройте в Eva") + except HttpError as e: + if e.code == 404: + return self._out("failed", error="файл не найден в Eva") + raise + except RedirectAway as e: + return self._out("failed", error="Eva перенаправила скачивание на %s" % e.host) + log(" вложение #%s (%d байт) <- Eva" % (p.get("attachment_id"), len(data))) + got = self._hub(self.hub.upload_file, data) + del data + return self._out("done", {"attachment_id": p.get("attachment_id"), "file": got.get("file"), "size": got.get("size")}) # --------------------------------------------------------------------------- diff --git a/tests/fake_eva.py b/tests/fake_eva.py index 484eda1..ab2ee26 100644 --- a/tests/fake_eva.py +++ b/tests/fake_eva.py @@ -25,7 +25,7 @@ class FakeEva: # форма ссылок parent/tree_parent в ответах: "full" — {"id": "", "code"} (по умолчанию, как в Eva), # "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка) self.ref_form = "full" - self.files = {} # путь → (байты, с Content-Length?) + self.files = {} # путь → (байты, Content-Length: True/False/заявленное число) self.requests = [] # {"method", "path", "body", "auth"} self.failures = [] # [status, retry_after] — по одному на ответ self.rpc_errors = [] @@ -121,8 +121,8 @@ class FakeEva: data, with_length = self.files[u.path] h.send_response(200) h.send_header("Content-Type", "application/octet-stream") - if with_length: - h.send_header("Content-Length", str(len(data))) + if with_length: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка) + h.send_header("Content-Length", str(len(data) if with_length is True else with_length)) h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения h.wfile.write(data) return diff --git a/tests/fake_hub.py b/tests/fake_hub.py index d8337c1..5075480 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer MAX_PAGES = 50 +MAX_FILE = 10 * 1024 * 1024 def check_pages(command, command_id, pages): @@ -185,6 +186,8 @@ class FakeHub: if self.reject_files: return self.reject_files data = base64.b64decode(body.get("content") or "", validate=True) + if len(data) > MAX_FILE: # как files.put хаба; пустой файл от агента принимается (allow_empty) + return 413, {"error": "too_large", "detail": "файл больше %d байт (%d)" % (MAX_FILE, len(data))} self.uploaded.append(data) return 200, {"file": self.put_file(data), "size": len(data)} m = re.match(r"^/agent/commands/(\d+)$", path) diff --git a/tests/test_folder.py b/tests/test_folder.py index 42f46ee..7b8bfc0 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -210,7 +210,7 @@ class FolderIntegrationTest(unittest.TestCase): runner.idle_limit = 1 runner.loop() self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor - self.assertEqual(self.hub.results[cid]["error"], "wiki_fetch: не реализовано") + self.assertEqual(self.hub.results[cid]["error"], "чужой хост") self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION) self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()]) diff --git a/tests/test_runner.py b/tests/test_runner.py index 7a091a9..721f060 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -12,7 +12,7 @@ from fake_hub import FakeHub FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"} -STUB_ERROR = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed +STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva class RunnerTest(unittest.TestCase): diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py new file mode 100644 index 0000000..be6ad03 --- /dev/null +++ b/tests/test_wiki_fetch.py @@ -0,0 +1,93 @@ +import hashlib +import os +import unittest + +from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_eva import FakeEva +from fake_hub import FakeHub + +MB = 1024 * 1024 +TOO_BIG = "файл больше 10 МБ — откройте в Eva" + + +class WikiFetchTest(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.hub = FakeHub(SETTINGS) + self.addCleanup(self.eva.stop) + self.addCleanup(self.hub.stop) + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) + client = agent.EvaClient(cfg) + client.sleep = no_sleep + self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS)) + + def fetch(self, url): + return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}}) + + def file_url(self, path="/files/a.bin"): + return self.eva.base_url + path + + def test_fetch_ok(self): + data = os.urandom(300 * 1024) + self.eva.add_file("/files/a.bin", data) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 307200) + self.assertEqual(out["result"]["file"], hashlib.sha256(data).hexdigest()) + self.assertEqual(self.hub.files[out["result"]["file"]], data) + self.assertEqual(self.eva.requests[0]["auth"], "Bearer " + self.eva.token) + + def test_fetch_foreign_host(self): + out = self.fetch("http://127.0.0.1:1/files/a.bin") + self.assertEqual(out["status"], "failed") + self.assertIn("чужой хост", out["error"]) + self.assertEqual(self.eva.requests, []) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_too_big_header(self): + self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], TOO_BIG) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_too_big_streaming(self): + self.eva.add_file("/files/a.bin", b"x" * (10 * MB + 1), length=False) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], TOO_BIG) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_exactly_limit(self): + self.eva.add_file("/files/a.bin", b"x" * (10 * MB)) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 10 * MB) + + def test_fetch_404(self): + out = self.fetch(self.file_url("/files/none.bin")) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "файл не найден в Eva") + + def test_fetch_empty_file(self): + self.eva.add_file("/files/a.bin", b"") + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 0) + self.assertEqual(self.hub.files[out["result"]["file"]], b"") + + def test_fetch_hub_error_is_hub_error(self): + self.eva.add_file("/files/a.bin", b"abc") + self.hub.reject_files = (500, {"error": "boom"}) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["error"].startswith("хаб:"), out) + self.assertTrue(out["retryable"]) + + def test_fetch_no_url(self): + out = self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7}}) + self.assertEqual(out["status"], "failed") + + +if __name__ == "__main__": + unittest.main() -- 2.47.2 From acdd52742e634aed75f53c7d54411fe679e69b5a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:32:32 +0400 Subject: [PATCH 09/15] =?UTF-8?q?check,=20README,=20=D1=81=D0=BA=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20=D1=81=20=D1=85=D0=B0=D0=B1=D0=BE=D0=BC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_check проверяет хаб и Eva (код 0 или 3), флаг run --once, scripts/e2e-hub.sh поднимает тестовый хаб во временном JIRA_HUB_HOME. Решения 33–35 в design.md. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 8 +- CLAUDE.md | 8 +- README.md | 35 +++++++- openspec/changes/eva-agent-mvp/design.md | 19 ++++ scripts/e2e-hub.sh | 108 +++++++++++++++++++++++ scripts/fake_eva_serve.py | 38 ++++++++ src/agent.example.json | 2 +- src/agent.py | 28 ++++-- tests/test_check.py | 77 ++++++++++++++++ tests/test_clients.py | 2 +- tests/test_folder.py | 7 +- tests/test_self_update_e2e.py | 5 +- 12 files changed, 315 insertions(+), 22 deletions(-) create mode 100755 scripts/e2e-hub.sh create mode 100644 scripts/fake_eva_serve.py create mode 100644 tests/test_check.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da5b7c9..dca0393 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` | | Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | -| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | +| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | ## Запускатель `src/run.py` @@ -36,5 +36,9 @@ - Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429. - Тесты не ходят во внешние сервисы. +## Сквозной прогон +`scripts/e2e-hub.sh`: настоящий хаб (`jira-hub serve` из worktree `jira-hub/.wt/wiki`, временный `JIRA_HUB_HOME`, свободный +порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются. + ## Открытые вопросы -- Проверка Eva в `check` и клиент JSON-RPC — задачи 2 и 6 плана. +- Живая проверка на виртуалке с настоящей Eva (после merge hub#92). diff --git a/CLAUDE.md b/CLAUDE.md index 8fdc5b1..79f5f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,9 @@ - `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор. - `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом). -- `/usr/bin/python3 src/agent.py check` — доступность хаба (и Eva — после задачи 6 плана), без записи. +- `/usr/bin/python3 src/agent.py check` — доступность хаба и Eva (`CmfDocument.list`, 1 запись), без записи; код 0 или 3. +- `/usr/bin/python3 src/agent.py run --once` — выполнить выданное и выйти при первом пустом claim. +- `bash scripts/e2e-hub.sh` — сквозной прогон с тестовым хабом из `../../../jira-hub/.wt/wiki` (временный `JIRA_HUB_HOME`, свободный порт, фейковая Eva); «e2e: ok». Живой хаб не трогать. - `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`). - `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет). - `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3. @@ -38,12 +40,12 @@ ## Тесты - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`, - `tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py` (появится вместе с клиентом Eva). + `tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py`; `scripts/fake_eva_serve.py` поднимает тот же фейк отдельным процессом для e2e. - `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`. - Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`, `test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.py` (самообновление), `test_launcher.py` (запускатель + `test_run_py_is_copy`), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py`). -- Golden-фикстур пока нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический. +- Фикстуры страниц — `tests/fixtures/pages/`. Golden-сценариев нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический. ## Первая подсистема MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева, diff --git a/README.md b/README.md index 39a9105..bae4b19 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,36 @@ Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, один файл `src/agent.py` и запускатель `src/run.py`. -Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`). +## Установка на виртуалку -Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить). -Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`. +1. Каталог рядом с jira-agent, например `C:\...\eva-agent\` или `~/eva-agent/`. +2. Положить в него `agent.py` и `run.py` (из `src/`) и `agent.json` (из `agent.example.json`): + - `hub_url` — тот же `folder:<путь>`, что у jira-agent (общая папка с хабом; при VPN виртуалка не видит Mac mini по + сети), либо `http://<хост>:8790`; + - `hub_token` — строка «агент wiki» из `jira-hub tokens` (токен именно агента wiki, не jira и не клиентский); + - `eva.base_url` — адрес Eva; `eva.token` — токен из профиля Eva (Безопасность); + - `verify_ssl`, `ca_bundle` — при корпоративном сертификате. + `agent.json` содержит токены: не коммитить, не выносить с виртуалки. +3. Проверка: `python3 agent.py check` — печатает «хаб: доступен…» и «eva: ok»; код 0, при недоступности хаба или Eva — 3. +4. Запуск: `python3 run.py` (запускатель следит за версиями и самообновлением). `python3 agent.py run` — без запускателя. +5. Автозапуск — как у jira-agent (планировщик задач / launchd по тому же образцу); запускатель сам перезапускает агента. + +Другие команды: `python3 agent.py run --once` (выполнить выданное и выйти при первом пустом claim), `selftest` +(версия + `check`, код 0 или 3), `prune` (чистка журнала). + +## Выкатка новых версий + +Из основного checkout репозитория (не из worktree), при поднятой версии `AGENT_VERSION` в `src/agent.py`: + + uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py + +Агент подтянет её сам (`auto_update`); `min_agent_version` хаба поднимать после того, как `agent_status` покажет новую. + +## Разработка + +Настройки — `src/agent.example.json`. Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` + +Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из +`../../../jira-hub/.wt/wiki` (переменная `HUB_SRC`), запускается на свободном порту с временным `JIRA_HUB_HOME`; +живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой +`attachment:`, `wiki_fetch` вложения и его содержимое. diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 121ef5d..bcfda91 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -193,3 +193,22 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. + +33. **`check` (задача 6): код возврата 0 или 3, оба узла проверяются всегда.** + - Хаб — `GET /agent/settings`, Eva — `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva. + - Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; `selftest` по-прежнему 0 или 3. Тесты каркаса приведены: `test_check_command` и `test_check_folder_silent_relay` ждут 3; `FolderIntegrationTest` и `SelfUpdateE2E` поднимают `FakeEva` (selftest = check, без доступной Eva самообновление откладывается). + - У Eva в `check` одна попытка (`ATTEMPTS = 1`): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на `***` (решение 23). + + Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там `selftest`). + +34. **Флаг `agent.py run --once` для сквозного прогона.** Ставит `runner.idle_limit = 1`: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, `Runner.idle_limit` уже был в каркасе (используют тесты). + Обоснование: прогон делает две фазы (`wiki_sync`, затем `wiki_fetch`, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с. + +35. **Сквозной прогон: `scripts/e2e-hub.sh` + `scripts/fake_eva_serve.py`.** + - Хаб — `uv run --project --no-sync jira-hub serve --host 127.0.0.1 --port <свободный>` с `JIRA_HUB_HOME=$(mktemp -d)/hub-home`; порт 8790 отвергается явно. `--no-sync`, чтобы прогон не менял `.venv` соседнего worktree. + - Токены берутся из `jira-hub tokens` (клиентский и «агент wiki»); транспорт агента — прямой HTTP, `auto_update=false`. + - Фейковая Eva (`tests/fake_eva.py`) запускается отдельным процессом, 5 страниц в проекте `E2E`, картинка `/files/obj/e2e/scheme.png`; проект создаётся `POST /projects {label, name, wiki_root}`, затем `wiki_sync`, `GET /wiki/status|tree|pages/DOC-2`, `POST /wiki/attachments//fetch`, `wiki_fetch`, `GET …/content` и сверка sha256. + - Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с. + - Хаб принял всё, что требует его спека (расхождений с хабом не найдено). + + Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных. diff --git a/scripts/e2e-hub.sh b/scripts/e2e-hub.sh new file mode 100755 index 0000000..2fae2f3 --- /dev/null +++ b/scripts/e2e-hub.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Сквозной прогон eva-agent с настоящим хабом (модуль wiki) и фейковой Eva. Печатает «e2e: ok». +# Хаб — ТОЛЬКО из worktree jira-hub/.wt/wiki, ТОЛЬКО с временным JIRA_HUB_HOME и портом не 8790; живой хаб не трогаем. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")/.." && pwd)" +HUB_SRC="${HUB_SRC:-$HERE/../../../jira-hub/.wt/wiki}" +HUB_SRC="$(cd "$HUB_SRC" && pwd)" +PORT="${E2E_PORT:-$(/usr/bin/python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')}" +[ "$PORT" != "8790" ] || { echo "e2e: порт 8790 занят живым хабом" >&2; exit 2; } + +TMP="$(mktemp -d)" +export JIRA_HUB_HOME="$TMP/hub-home" +mkdir -p "$JIRA_HUB_HOME" +PIDS=() +stop_tree() { # остановить процесс и его детей (uv run запускает хаб дочерним процессом) + local p="$1" c + for c in $(pgrep -P "$p" 2>/dev/null || true); do stop_tree "$c"; done + kill "$p" 2>/dev/null || true +} +cleanup() { + trap - EXIT INT TERM + for p in ${PIDS[@]+"${PIDS[@]}"}; do stop_tree "$p"; done + for _ in 1 2 3 4 5 6 7 8 9 10; do + alive=0 + for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -0 "$p" 2>/dev/null && alive=1; done + [ "$alive" = 0 ] && break + sleep 0.2 + done + for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -9 "$p" 2>/dev/null || true; done + rm -rf "$TMP" +} +trap cleanup EXIT INT TERM + +hub() { uv run --project "$HUB_SRC" --no-sync jira-hub "$@"; } +fail() { echo "e2e: ОШИБКА — $*" >&2; echo "--- лог хаба ---" >&2; tail -n 30 "$TMP/hub.log" >&2 || true; exit 1; } +jget() { /usr/bin/python3 -c 'import json,sys; d=json.load(sys.stdin) +for k in sys.argv[1].split("."): + d = d[int(k)] if isinstance(d, list) else d[k] +print(d)' "$1"; } + +# --- токены (создают базу во временном каталоге) +TOKENS="$(hub tokens)" +CLIENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^клиентский токен: //p')" +AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')" +[ -n "$CLIENT_TOKEN" ] && [ -n "$AGENT_TOKEN" ] || fail "токены не выданы: $TOKENS" + +# --- фейковая Eva +EVA_TOKEN="eva-e2e-secret" +/usr/bin/python3 "$HERE/scripts/fake_eva_serve.py" "$EVA_TOKEN" >"$TMP/eva.out" 2>"$TMP/eva.err" & +PIDS+=($!) +for _ in $(seq 1 50); do grep -q '^PORT ' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done +EVA_PORT="$(sed -n 's/^PORT //p' "$TMP/eva.out")" +[ -n "$EVA_PORT" ] || fail "фейковая Eva не поднялась: $(cat "$TMP/eva.err")" + +# --- хаб +hub serve --host 127.0.0.1 --port "$PORT" >"$TMP/hub.log" 2>&1 & +PIDS+=($!) +HUB="http://127.0.0.1:$PORT" +for _ in $(seq 1 100); do + nc -z 127.0.0.1 "$PORT" 2>/dev/null && break + kill -0 "${PIDS[1]}" 2>/dev/null || fail "хаб завершился при старте" + sleep 0.1 +done +nc -z 127.0.0.1 "$PORT" 2>/dev/null || fail "хаб не открыл порт $PORT" + +api() { # api METHOD PATH [json] + local m="$1" p="$2"; shift 2 + if [ $# -gt 0 ]; then + curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" -H 'Content-Type: application/json' -d "$1" "$HUB$p" + else + curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" "$HUB$p" + fi +} + +# --- конфиг агента (прямой HTTP к тестовому хабу, без самообновления) +cat >"$TMP/agent.json" < пакет wiki_sync +api POST /projects '{"label":"E2E","name":"Сквозной прогон","wiki_root":"DOC-1"}' >"$TMP/project.json" || fail "POST /projects" +BATCH="$(jget batch_id <"$TMP/project.json")" +agent run --once || fail "agent.py run --once (wiki_sync)" + +STATUS="$(api GET "/wiki/status?project=E2E")" +[ "$(printf '%s' "$STATUS" | jget pages)" = "5" ] || fail "wiki/status: ожидалось 5 страниц: $STATUS" +TREE="$(api GET "/wiki/tree?project=E2E")" +[ "$(printf '%s' "$TREE" | /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["pages"]))')" = "5" ] || fail "wiki/tree: $TREE" + +PAGE="$(api GET "/wiki/pages/DOC-2?project=E2E")" +printf '%s' "$PAGE" | jget markdown | grep -q 'attachment:[0-9]' || fail "в Markdown DOC-2 нет ссылки attachment:: $PAGE" +ATT_ID="$(printf '%s' "$PAGE" | jget attachments.0.id)" + +# --- вложение по запросу -> wiki_fetch +api POST "/wiki/attachments/$ATT_ID/fetch" '{}' >/dev/null || fail "POST attachments/$ATT_ID/fetch" +agent run --once || fail "agent.py run --once (wiki_fetch)" +api GET "/wiki/attachments/$ATT_ID/content" >"$TMP/content.bin" || fail "GET attachments/$ATT_ID/content" +EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import fake_eva_serve as f; sys.stdout.buffer.write(f.PNG)' "$HERE/scripts" | shasum -a 256 | cut -d' ' -f1)" +GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)" +[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало" + +echo "e2e: ok" diff --git a/scripts/fake_eva_serve.py b/scripts/fake_eva_serve.py new file mode 100644 index 0000000..c319169 --- /dev/null +++ b/scripts/fake_eva_serve.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл. + +Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом. +""" +import os +import sys +import threading + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests")) +from fake_eva import FakeEva # noqa: E402 + +PNG = bytes.fromhex("89504e470d0a1a0a") + b"e2e-attachment-content" + + +def build(eva: FakeEva) -> None: + img = '

Схема:

Схема' + eva.add_doc(1, "DOC-1", "Корень e2e", project="E2E", text="

корень

") + eva.add_doc(2, "DOC-2", "Требования", parent="DOC-1", project="E2E", orderno=1, text=img) + eva.add_doc(3, "DOC-3", "Процессы", parent="DOC-1", project="E2E", orderno=2, + text='

см. требования

') + eva.add_doc(4, "DOC-4", "Подпроцесс", parent="DOC-3", project="E2E", text="

подпроцесс

") + eva.add_doc(5, "DOC-5", "Приложение", parent="DOC-1", project="E2E", orderno=3, text="

приложение

") + eva.add_file("/files/obj/e2e/scheme.png", PNG) + + +def main() -> int: + eva = FakeEva() + if len(sys.argv) > 1: + eva.token = sys.argv[1] + build(eva) + print("PORT %d" % eva.server.server_port, flush=True) + threading.Event().wait() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agent.example.json b/src/agent.example.json index 1bd733b..2457a8c 100644 --- a/src/agent.example.json +++ b/src/agent.example.json @@ -1,5 +1,5 @@ { - "hub_url": "http://192.168.0.106:8790", + "hub_url": "folder:/путь/к/общей/папке/jira-bridge", "hub_token": "токен агента wiki из `jira-hub tokens`", "eva": { "base_url": "https://eva.example.ru", diff --git a/src/agent.py b/src/agent.py index 8c71d34..fee23fd 100644 --- a/src/agent.py +++ b/src/agent.py @@ -6,8 +6,8 @@ jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/. Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. - python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи - python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py check # доступ к хабу и Eva, без записи + python3 agent.py run # основной цикл (по умолчанию); run --once — выполнить выданное и выйти (сквозной прогон) python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней @@ -1258,13 +1258,13 @@ def acquire_lock(journal_path: str): return f -def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: +def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int: lock = acquire_lock(journal_path) if lock is None: log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) return EXIT_LOCKED try: - return _run(cfg, journal_path, config_path) + return _run(cfg, journal_path, config_path, once) finally: lock.close() @@ -1292,8 +1292,10 @@ def launcher_version() -> Optional[int]: return None -def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: +def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int: runner = Runner(cfg, HubClient(cfg), EvaClient(cfg), Journal(journal_path)) + if once: + runner.idle_limit = 1 # выполнить всё, что выдаст хаб, и выйти при первом пустом claim runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path) runner.updater.should_stop = lambda: runner.stop @@ -1333,13 +1335,20 @@ def cmd_check(cfg: dict) -> int: except (ConnectionError, HttpError) as e: log("хаб: НЕДОСТУПЕН — %s" % e) ok = False - # проверка Eva — в задаче 6 плана - return 0 if ok else 1 + eva = EvaClient(cfg) + eva.ATTEMPTS = 1 # проверка отвечает сразу, без повторов с паузами + try: + eva.call("CmfDocument.list", filter=[], fields=["code"], slice=[0, 1]) + log("eva: ok") + except (ConnectionError, HttpError, EvaError) as e: + log("eva: НЕДОСТУПНА — %s" % e if isinstance(e, ConnectionError) else "eva: ошибка — %s" % e) + ok = False + return 0 if ok else 3 def cmd_selftest(cfg: dict) -> int: """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: - 0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" + 0 — хаб и Eva доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" log("eva-agent v%d" % AGENT_VERSION) return 0 if cmd_check(cfg) == 0 else 3 @@ -1348,6 +1357,7 @@ def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("--once", action="store_true", help="для run: выполнить выданные команды и выйти при первом пустом claim") ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"]) args = ap.parse_args(argv) cfg = load_config(args.config) @@ -1357,7 +1367,7 @@ def main(argv=None) -> int: return cmd_selftest(cfg) if args.cmd == "prune": return cmd_prune(args.journal) - return cmd_run(cfg, args.journal, args.config) + return cmd_run(cfg, args.journal, args.config, args.once) if __name__ == "__main__": diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..a36bc21 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,77 @@ +import contextlib +import io +import unittest + +from _common import SETTINGS, agent, eva_cfg +from fake_eva import FakeEva +from fake_hub import FakeHub + + +class CheckTest(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.hub = FakeHub(SETTINGS) + self.addCleanup(self.eva.stop) + self.addCleanup(self.hub.stop) + self.eva.add_doc(1, "DOC-1", "К", project="P") + + def check(self, **over): + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, **over) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = agent.cmd_check(cfg) + return code, out.getvalue() + + def test_all_ok(self): + code, out = self.check() + self.assertEqual(code, 0, out) + self.assertIn("хаб: ", out) + self.assertIn("eva: ok", out) + call = self.eva.api_calls[-1]["body"] + self.assertEqual(call["method"], "CmfDocument.list") + self.assertEqual(call["kwargs"], {"filter": [], "fields": ["code"], "slice": [0, 1]}) + + def test_eva_401(self): + self.eva.token = "другой" + code, out = self.check() + self.assertEqual(code, 3) + self.assertIn("eva: ", out) + self.assertIn("401", out) + self.assertNotIn("eva-secret", out) + + def test_eva_unreachable(self): + cfg = eva_cfg("http://127.0.0.1:1", hub_url=self.hub.base_url, hub_token=self.hub.token) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.cmd_check(cfg), 3) + self.assertIn("eva: НЕДОСТУПНА", out.getvalue()) + + def test_hub_down_but_eva_checked(self): + cfg = eva_cfg(self.eva.base_url) # хаб: порт 1 + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.cmd_check(cfg), 3) + self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) + self.assertIn("eva: ok", out.getvalue()) + + def test_selftest_zero_when_ok(self): + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(agent.cmd_selftest(cfg), 0) + + def test_run_once_exits_on_idle(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, auto_update=False) + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f) + with contextlib.redirect_stdout(io.StringIO()): + code = agent.main(["--config", path, "--journal", os.path.join(d, "j.jsonl"), "run", "--once"]) + self.assertEqual(code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_clients.py b/tests/test_clients.py index 1d07005..67dd2ad 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -236,7 +236,7 @@ class ClientsTest(unittest.TestCase): json.dump(eva_cfg(), f) out = io.StringIO() with contextlib.redirect_stdout(out): - self.assertEqual(agent.main(["--config", path, "check"]), 1) # хаб недоступен (порт 1) + self.assertEqual(agent.main(["--config", path, "check"]), 3) # хаб и Eva недоступны (порт 1) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) def test_selftest_command(self): diff --git a/tests/test_folder.py b/tests/test_folder.py index 7b8bfc0..32fe055 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -10,6 +10,7 @@ from contextlib import redirect_stdout from datetime import datetime, timezone from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_eva import FakeEva from fake_hub import FakeHub from fake_relay import FakeRelay @@ -187,15 +188,17 @@ class FolderIntegrationTest(unittest.TestCase): self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") os.makedirs(self.root) self.hub = FakeHub(SETTINGS) + self.eva = FakeEva() # check ходит и в Eva (design 10) self.relay = FakeRelay(self.root, self.hub.base_url).start() def tearDown(self): self.relay.stop() self.hub.stop() + self.eva.stop() self.tmp.cleanup() def cfg(self): - return eva_cfg(hub_url="folder:" + self.root, hub_token=self.hub.token) + return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): self.assertEqual(agent.AGENT_VERSION, 1) @@ -228,7 +231,7 @@ class FolderIntegrationTest(unittest.TestCase): t0 = time.monotonic() with redirect_stdout(out): code = agent.cmd_check(self.cfg()) - self.assertEqual(code, 1) + self.assertEqual(code, 3) self.assertLess(time.monotonic() - t0, 13) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) self.assertIn("не ответил через папку", out.getvalue()) diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py index e0cad78..23da247 100644 --- a/tests/test_self_update_e2e.py +++ b/tests/test_self_update_e2e.py @@ -11,6 +11,7 @@ import time import unittest from _common import SETTINGS, SRC +from fake_eva import FakeEva from fake_hub import FakeHub @@ -28,13 +29,14 @@ class SelfUpdateE2E(unittest.TestCase): def setUp(self): self.hub = FakeHub(SETTINGS) + self.eva = FakeEva() # selftest = check: нужна доступная Eva self.tmp = tempfile.TemporaryDirectory() d = self.tmp.name shutil.copy(os.path.join(SRC, "run.py"), d) shutil.copy(os.path.join(SRC, "agent.py"), d) with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f: json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2, - "eva": {"base_url": "http://127.0.0.1:1", "token": "eva-secret"}}, f) + "eva": {"base_url": self.eva.base_url, "token": "eva-secret"}}, f) with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: src = f.read() self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1)) @@ -54,6 +56,7 @@ class SelfUpdateE2E(unittest.TestCase): self.proc.wait() self.log.close() self.hub.stop() + self.eva.stop() self.tmp.cleanup() def state(self): -- 2.47.2 From a51c509a872443eea31be5f428eb99520a5ac9f9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:46:28 +0400 Subject: [PATCH 10/15] =?UTF-8?q?classify=5Flinks=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=B1=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20HTML=20=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=B0=D1=85=20(I-1,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На Python 3.9 `` вызывал NotImplementedError из ParserBase.error(), а адрес вида `http://[::1` — ValueError в urljoin/urlsplit; одна такая конструкция роняла wiki_sync целиком. Теперь нестандартная секция пропускается до `>`, битая ссылка — пропускается, остальные ссылки страницы сохраняются. Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 31 +++++++++++++++++++++++++++---- tests/test_links.py | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/agent.py b/src/agent.py index fee23fd..277fc57 100644 --- a/src/agent.py +++ b/src/agent.py @@ -331,14 +331,31 @@ class EvaClient(Http): return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) +class _ParseError(Exception): + """Разбор HTML споткнулся (ParserBase.error в 3.9): см. _LinkParser.error.""" + + class _LinkParser(HTMLParser): - """Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки).""" + """Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки). + + На 3.9 `_markupbase` зовёт error() на `` и подобном, а базовый error() бросает NotImplementedError (I-1). + error() бросает _ParseError, а parse_marked_section пропускает такую конструкцию до `>`, как мусорный комментарий.""" def __init__(self): HTMLParser.__init__(self, convert_charrefs=True) self.items = [] # [tag, ref, hint, text] self._open = None # запись открытой , в неё копится текст + def error(self, message): + raise _ParseError(message) + + def parse_marked_section(self, i, report=1): + try: + return HTMLParser.parse_marked_section(self, i, report) + except _ParseError: + j = self.rawdata.find(">", i + 3) + return -1 if j < 0 else j + 1 # -1: ждать данных; при close() goahead сам пропустит до `>` + def handle_starttag(self, tag, attrs): a = dict(attrs) if tag == "img": @@ -374,12 +391,18 @@ def classify_links(html: str, base_url: str) -> Tuple[list, list]: ref — значение атрибута после unescape (design 12). Чужой хост и static/ не передаются.""" parser = _LinkParser() - parser.feed(html or "") - parser.close() + try: + parser.feed(html or "") + parser.close() + except (_ParseError, NotImplementedError, AssertionError) as e: # запасной путь: собранное до сбоя сохраняется + log(" разбор HTML прерван (%s: %s), ссылок до сбоя: %d" % (type(e).__name__, e, len(parser.items))) base = urllib.parse.urlsplit(base_url) attachments, links = [], [] for _tag, ref, hint, text in parser.items: - parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip())) + try: + parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip())) + except ValueError: # `http://[::1` — ссылка битая, пропускаем (I-1) + continue if (parts.scheme, parts.netloc) != (base.scheme, base.netloc): continue path = parts.path diff --git a/tests/test_links.py b/tests/test_links.py index fd9b7cd..b6ee710 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -78,6 +78,29 @@ class ClassifyLinksTest(unittest.TestCase): '' % (OBJ, OBJ, OBJ), BASE) self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"]) + def test_unknown_marked_section_does_not_crash(self): + """I-1: на 3.9 `` уходит в ParserBase.error() → NotImplementedError; ссылки до и после сохраняются.""" + html = ('

текст

x' + 'c' % (OBJ, OBJ)) + att, links = agent.classify_links(html, BASE) + self.assertEqual([a["name"] for a in att], ["a.png", "b.png"]) + self.assertEqual(links, [{"ref": "/project/Document/DOC-3", "code": "DOC-3"}]) + + def test_marked_section_bad_name_and_unclosed(self): + for html in ('' % (OBJ, OBJ), + 'x' + 'd' % OBJ) + att, links = agent.classify_links(html, BASE) + self.assertEqual([a["name"] for a in att], ["b.png"]) + self.assertEqual([l["code"] for l in links], ["DOC-4"]) + if __name__ == "__main__": unittest.main() -- 2.47.2 From 4a410d4b08b52883efcfcef7136989867a9a5fdc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:48:14 +0400 Subject: [PATCH 11/15] =?UTF-8?q?wiki=5Ffetch:=20=D0=BA=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B0=20=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20ForeignHost=20(I-2,=20T5.2,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Адрес вложения из HTML бывает с пробелами и кириллицей: путь и запрос теперь кодируются перед запросом, готовые %XX не кодируются повторно, одиночный % становится %25. Отказ по хосту — ForeignHost («чужой хост» или «некорректный адрес»), а не голый ValueError: UnicodeEncodeError и прочие ValueError больше не маскируются под «чужой хост». Добавлены тесты на похожий хост, userinfo, другой порт и схему. Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 34 +++++++++++++++++++++++++------ tests/test_eva_client.py | 17 +++++++++++++++- tests/test_wiki_fetch.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/agent.py b/src/agent.py index 277fc57..8008f48 100644 --- a/src/agent.py +++ b/src/agent.py @@ -238,6 +238,20 @@ class EvaError(Exception): """JSON-RPC Eva вернула `error`: текст «<метод>: ».""" +class ForeignHost(Exception): + """Адрес вложения не на хосте Eva (или не разбирается): токен туда не отправляется, запроса нет.""" + + +_URL_SAFE = "/%:@!$&'()*+,;=-._~" +_STRAY_PERCENT = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +def _quote_url_part(part: str, extra_safe: str = "") -> str: + """Процентная кодировка пути/запроса для HTTP: пробелы и не-ASCII кодируются, готовые %XX остаются как есть, + одиночный `%` без двух шестнадцатеричных цифр становится %25 (I-2).""" + return urllib.parse.quote(_STRAY_PERCENT.sub("%25", part), safe=_URL_SAFE + extra_safe) + + class EvaClient(Http): """Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова.""" PAGE_SIZE = 200 @@ -325,10 +339,18 @@ class EvaClient(Http): offset += self.PAGE_SIZE def download(self, url: str, limit: int) -> bytes: - """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста.""" - if _origin(url) != _origin(self.base) or not url.startswith(self.base): - raise ValueError("чужой хост") - return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) + """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста + (иначе ForeignHost); путь и запрос перед отправкой кодируются (адрес из HTML бывает с пробелами и кириллицей).""" + try: + same = _origin(url) == _origin(self.base) + except ValueError: # `http://[::1`, порт не числом + raise ForeignHost("некорректный адрес") from None + rest = url[len(self.base):] if url.startswith(self.base) else None + if not same or rest is None or rest[:1] not in ("", "/", "?"): + raise ForeignHost("чужой хост") + path, _, query = rest.split("#", 1)[0].partition("?") + path = _quote_url_part(path) + ("?" + _quote_url_part(query, "?") if query else "") + return self._retrying(lambda: self.fetch_bytes(path, limit)) class _ParseError(Exception): @@ -870,8 +892,8 @@ class Executor: return self._out("failed", error="wiki_fetch: не задан url") try: data = self.eva.download(url, MAX_ATTACHMENT) - except ValueError: - return self._out("failed", error="чужой хост") + except ForeignHost as e: + return self._out("failed", error=str(e)) except TooBig: return self._out("failed", error="файл больше 10 МБ — откройте в Eva") except HttpError as e: diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index 292c1ce..6e141e1 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -101,10 +101,25 @@ class EvaClientTest(unittest.TestCase): self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret") def test_download_foreign_host(self): - with self.assertRaises(ValueError): + with self.assertRaises(agent.ForeignHost): self.cli.download("http://example.invalid/files/a.bin", 10) self.assertEqual(self.eva.requests, []) + def test_download_lookalikes(self): + """T5.2: похожий хост, userinfo, другой порт и схема — ForeignHost до запроса (адрес Eva — https://eva.test).""" + cli = agent.EvaClient(c.eva_cfg("https://eva.test")) + cli.fetch_bytes = lambda *a, **kw: self.fail("запроса быть не должно") + for url in ("https://eva.test.evil/files/a.bin", "https://eva.test@evil/files/a.bin", + "https://eva.test:8443/files/a.bin", "http://eva.test/files/a.bin", + "https://eva.testx/files/a.bin", "https://user@eva.test/files/a.bin"): + with self.subTest(url=url): + with self.assertRaises(agent.ForeignHost) as cm: + cli.download(url, 10) + self.assertEqual(str(cm.exception), "чужой хост") + with self.assertRaises(agent.ForeignHost) as cm: + cli.download("https://eva.test:abc/files/a.bin", 10) + self.assertEqual(str(cm.exception), "некорректный адрес") + def test_download_limit(self): limit = 10 * 1024 * 1024 self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False) diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py index be6ad03..ac4918f 100644 --- a/tests/test_wiki_fetch.py +++ b/tests/test_wiki_fetch.py @@ -44,6 +44,49 @@ class WikiFetchTest(unittest.TestCase): self.assertEqual(self.eva.requests, []) self.assertEqual(self.hub.uploaded, []) + def test_fetch_foreign_lookalikes(self): + """T5.2: похожий хост, userinfo, другой порт и схема — «чужой хост», запроса к Eva нет.""" + port = self.eva.base_url.rsplit(":", 1)[1] # http://127.0.0.1: + for url in ("http://127.0.0.1.evil:%s/files/a.bin" % port, + "http://127.0.0.1:%s@evil.test/files/a.bin" % port, + "http://127.0.0.1:%d/files/a.bin" % (int(port) + 1), + "https://127.0.0.1:%s/files/a.bin" % port): + with self.subTest(url=url): + out = self.fetch(url) + self.assertEqual((out["status"], out["error"]), ("failed", "чужой хост")) + self.assertEqual(self.eva.requests, []) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_malformed_url(self): + out = self.fetch("http://[::1/files/a.bin") + self.assertEqual(out["status"], "failed") + self.assertIn("некорректный адрес", out["error"]) + self.assertEqual(self.eva.requests, []) + + def test_fetch_non_ascii_and_spaces(self): + """I-2: адрес из HTML с пробелами и кириллицей кодируется перед запросом; %XX не кодируется повторно.""" + enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png" + self.eva.add_file(enc, b"png") + for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"): + with self.subTest(path=path): + out = self.fetch(self.file_url(path)) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 3) + self.assertEqual([r["path"] for r in self.eva.requests], [enc] * 3) + + def test_fetch_stray_percent(self): + self.eva.add_file("/files/100%25.txt", b"ok") + out = self.fetch(self.file_url("/files/100%.txt")) + self.assertEqual(out["status"], "done", out) + + def test_value_error_inside_is_not_foreign_host(self): + """I-2: ValueError внутри скачивания (например, UnicodeEncodeError) не выдаётся за «чужой хост».""" + def boom(*a, **kw): + raise UnicodeEncodeError("ascii", "я", 0, 1, "ordinal not in range") + self.ex.eva.fetch_bytes = boom + with self.assertRaises(UnicodeEncodeError): + self.fetch(self.file_url()) + def test_fetch_too_big_header(self): self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт out = self.fetch(self.file_url()) -- 2.47.2 From c9fd3d6290d143ed6e4fa4c0bbb86241c08a64b2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:51:45 +0400 Subject: [PATCH 12/15] =?UTF-8?q?=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20Ev?= =?UTF-8?q?a=20=D0=B8=20413=20=D1=85=D0=B0=D0=B1=D0=B0=20=D0=B2=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20list=5Fdocs=20?= =?UTF-8?q?(M-1,=20M-3,=20M-4,=20M-7,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EvaError (Eva вернула `error`) — failed «Eva: <текст>» без повтора, а не «внутренняя ошибка агента». - 413 хаба на пачку страниц — в ошибке названа страница («страница DOC-x больше 16 МБ») или границы пачки. - list_docs останавливается, если Eva не соблюдает slice и ответ не приносит новых записей. - Удалены неиспользуемые Executor.s и Executor.sleep; Executor(eva, hub). Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 41 ++++++++++++++++++++++++++++++++-------- tests/test_eva_client.py | 20 ++++++++++++++++++++ tests/test_wiki_fetch.py | 2 +- tests/test_wiki_sync.py | 38 ++++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/agent.py b/src/agent.py index 8008f48..2b115c1 100644 --- a/src/agent.py +++ b/src/agent.py @@ -327,15 +327,27 @@ class EvaClient(Http): return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields) def list_docs(self, flt: list, fields: List[str]) -> List[dict]: + """Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход + останавливается на собранном, а не крутится бесконечно (M-4).""" out, offset = [], 0 # type: List[dict], int + seen = set() # type: set while True: chunk = self.call("CmfDocument.list", filter=flt, fields=fields, slice=[offset, offset + self.PAGE_SIZE]) or [] if isinstance(chunk, dict): # обёрнутый результат chunk = chunk.get("items") or chunk.get("result") or [] - out.extend(chunk) + fresh = 0 + for d in chunk: + key = json.dumps(d, sort_keys=True, ensure_ascii=False) + if key not in seen: + seen.add(key) + out.append(d) + fresh += 1 if len(chunk) < self.PAGE_SIZE: return out + if not fresh: + log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out)) + return out offset += self.PAGE_SIZE def download(self, url: str, limit: int) -> bytes: @@ -808,9 +820,8 @@ class HubError(Exception): class Executor: - def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict): - self.eva, self.hub, self.s = eva, hub, settings - self.sleep = time.sleep + def __init__(self, eva: EvaClient, hub: Optional[HubClient]): + self.eva, self.hub = eva, hub def run(self, cmd: dict) -> dict: self.eva.reset_counters() @@ -824,6 +835,8 @@ class Executor: out["retryable"] = e.retryable out["peer"] = "хабом" return out + except EvaError as e: # Eva ответила `error` (нет прав, неверный фильтр): повтор не поможет (M-1) + return self._out("failed", error="Eva: %s" % e) # текст уже очищен от токена (решение 23) except HttpError as e: out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит @@ -844,6 +857,19 @@ class Executor: except (ConnectionError, HttpError) as e: raise HubError(e) from None + def _send_pages(self, command_id, batch: list) -> None: + """Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3).""" + try: + self.hub.wiki_pages(command_id, batch) + except HttpError as e: + if e.code == 413: + what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else + "пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch))) + e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers) + raise HubError(e) from None + except ConnectionError as e: + raise HubError(e) from None + def do_wiki_sync(self, p: dict, cmd: dict) -> dict: """Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается, поэтому после перезапуска команда выполняется заново целиком (design 9).""" @@ -874,13 +900,13 @@ class Executor: "html": html, "attachments": attachments, "links": links} psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2 if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES): - self._hub(self.hub.wiki_pages, cmd["id"], batch) + self._send_pages(cmd["id"], batch) batch, size = [], 0 batch.append(page) size += psize codes.append(code) if batch: - self._hub(self.hub.wiki_pages, cmd["id"], batch) + self._send_pages(cmd["id"], batch) title = (root.get("name") or "").strip() or root_code return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) @@ -1086,8 +1112,7 @@ class Runner: def load_settings(self) -> None: self.settings = self.hub.settings() - self.executor = Executor(self.eva, self.hub, self.settings) - self.executor.sleep = self.sleep + self.executor = Executor(self.eva, self.hub) self.eva.pacing = float(self.settings.get("wiki_pacing", 0.2)) log("настройки хаба v%s" % self.settings.get("settings_version")) diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index 6e141e1..0349714 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -74,6 +74,26 @@ class EvaClientTest(unittest.TestCase): self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]]) + def test_list_slice_ignored_returns_all(self): + """M-4: Eva не поддерживает slice и отдаёт весь список каждый раз — второй ответ без новых записей, стоп.""" + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None)) + for i in range(450): + self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + self.assertEqual(len(docs), 450) + self.assertEqual(len(self.eva.api_calls), 2) + + def test_list_slice_ignored_same_page(self): + """M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен.""" + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200])) + for i in range(450): + self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)]) + self.assertEqual(len(self.eva.api_calls), 2) + def test_list_unwraps_dict(self): orig = self.eva.rpc self.eva.rpc = lambda m, kw: {"items": orig(m, kw)} diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py index ac4918f..4d99ec9 100644 --- a/tests/test_wiki_fetch.py +++ b/tests/test_wiki_fetch.py @@ -19,7 +19,7 @@ class WikiFetchTest(unittest.TestCase): cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) client = agent.EvaClient(cfg) client.sleep = no_sleep - self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS)) + self.ex = agent.Executor(client, agent.HubClient(cfg)) def fetch(self, url): return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}}) diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 5ac9beb..c051025 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -17,7 +17,7 @@ class SyncBase(unittest.TestCase): self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) self.client = agent.EvaClient(self.cfg) self.client.sleep = no_sleep - self.ex = agent.Executor(self.client, agent.HubClient(self.cfg), dict(SETTINGS)) + self.ex = agent.Executor(self.client, agent.HubClient(self.cfg)) def sync(self, root="DOC-1", label="tz"): cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root}) @@ -168,6 +168,42 @@ class WikiSyncTest(SyncBase): self.assertIn("422", out["error"]) self.assertIn("DOC-1: html", out["error"]) + def test_eva_rpc_error_reported(self): + """M-1: `error` JSON-RPC Eva — failed «Eva: <текст>», без повтора и без «внутренней ошибки агента».""" + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.eva.rpc_error_next("нет прав, токен eva-secret") + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "Eva: CmfDocument.get: нет прав, токен ***") + self.assertFalse(out.get("retryable")) + self.assertEqual(self.hub.pages, []) + + def test_eva_rpc_error_in_runner(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.eva.rpc_error_next("нет прав") + runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.devnull)) + runner.executor = self.ex + self.assertEqual(runner.run_once({"id": 1, "type": "wiki_sync", "params": {"root": "DOC-1"}})["error"], + "Eva: CmfDocument.get: нет прав") + + def test_hub_413_single_page(self): + """M-3: хаб не принял тело (413) — в ошибке названа страница.""" + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"}) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("страница DOC-1 больше 16 МБ", out["error"]) + self.assertFalse(out.get("retryable")) + + def test_hub_413_batch(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + for i in (2, 3): + self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i) + self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"}) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("пачка страниц DOC-1…DOC-3 (3 шт.) больше 16 МБ", out["error"]) + def test_empty_name_falls_back_to_code(self): self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check) out = self.sync() -- 2.47.2 From 343c49fc2e8ef64721c2f112b06da731d371268d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:56:57 +0400 Subject: [PATCH 13/15] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20I-2=20?= =?UTF-8?q?=D0=B8=20M-4=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B0=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_eva_client.py | 8 ++++++-- tests/test_wiki_fetch.py | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index 0349714..c3a6002 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -80,9 +80,12 @@ class EvaClientTest(unittest.TestCase): self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None)) for i in range(450): self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") - docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + out = io.StringIO() + with redirect_stdout(out): + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) self.assertEqual(len(docs), 450) self.assertEqual(len(self.eva.api_calls), 2) + self.assertIn("slice не соблюдается", out.getvalue()) def test_list_slice_ignored_same_page(self): """M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен.""" @@ -90,7 +93,8 @@ class EvaClientTest(unittest.TestCase): self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200])) for i in range(450): self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") - docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + with redirect_stdout(io.StringIO()): + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)]) self.assertEqual(len(self.eva.api_calls), 2) diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py index 4d99ec9..480d84d 100644 --- a/tests/test_wiki_fetch.py +++ b/tests/test_wiki_fetch.py @@ -1,6 +1,8 @@ import hashlib +import io import os import unittest +from contextlib import redirect_stdout from _common import SETTINGS, agent, eva_cfg, no_sleep from fake_eva import FakeEva @@ -68,7 +70,7 @@ class WikiFetchTest(unittest.TestCase): enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png" self.eva.add_file(enc, b"png") for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"): - with self.subTest(path=path): + with self.subTest(path=path), redirect_stdout(io.StringIO()): out = self.fetch(self.file_url(path)) self.assertEqual(out["status"], "done", out) self.assertEqual(out["result"]["size"], 3) @@ -76,7 +78,8 @@ class WikiFetchTest(unittest.TestCase): def test_fetch_stray_percent(self): self.eva.add_file("/files/100%25.txt", b"ok") - out = self.fetch(self.file_url("/files/100%.txt")) + with redirect_stdout(io.StringIO()): + out = self.fetch(self.file_url("/files/100%.txt")) self.assertEqual(out["status"], "done", out) def test_value_error_inside_is_not_foreign_host(self): -- 2.47.2 From cf7c7bde8c5a0832f322598a8c14886b343fae58 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:56:57 +0400 Subject: [PATCH 14/15] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2036=E2=80=9343:=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BA=D0=B0=20--module=20wiki,=20base=5Furl,?= =?UTF-8?q?=20launchd=20(I-3,=20T6.1,=20T6.4,=20M-2,=20M-6,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: `jira-hub agent publish --module wiki src/agent.py`, порог `wiki_min_agent_version`. - README: `wiki_min_agent_version` вместо `min_agent_version`; `eva.base_url` — только origin; агент только для macOS (fcntl), пример launchd `ru.aepif.eva-agent`; строка про проверку SSO на первой живой синхронизации (M-5). - ARCHITECTURE: строки Eva и Executor по реализованному, строки ссылок (`_LinkParser`, `classify_links`) и дерева (`build_children`, `walk`). - design.md: решения 36–43, в том числе почему M-5 отложен до живой проверки. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 8 +++- CLAUDE.md | 3 +- README.md | 53 ++++++++++++++++++++-- openspec/changes/eva-agent-mvp/design.md | 58 ++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dca0393..61d47dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,11 +11,13 @@ | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — | | Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` | | HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` | -| Eva | `EvaClient` | клиент Eva (Bearer-токен): счётчики вызовов и 429, время последнего вызова. Каркас; JSON-RPC и повторы — в задачах 2–3 | `Http` | +| Eva | `EvaClient`, `EvaError`, `ForeignHost`, `_quote_url_part` | клиент Eva (Bearer-токен): JSON-RPC 2.2 `call` (`POST /api/?m=<метод>`, `error` в ответе — `EvaError`), повторы 429/502/503/504 и сетевых сбоев (4 попытки, `Retry-After`), пауза `pacing` между вызовами, токен вырезается из текстов ошибок; `get_doc`, `list_docs` (страницы по 200, стоп, если Eva не соблюдает `slice`), `download` (только адрес своего хоста, иначе `ForeignHost`; путь кодируется перед запросом; предел 10 МБ); счётчики вызовов и 429, время последнего вызова | `Http` | +| Ссылки | `_LinkParser`, `_ParseError`, `classify_links` | `_LinkParser` (`html.parser`) собирает `img[src]` и `a[href]` в порядке появления, нестандартные `` пропускает; `classify_links` делит их на вложения `{ref, url, name}` (свой хост, `/files/…`, миниатюра → файл) и ссылки на страницы `{ref, code}` (`DOC-<цифры>`); чужой хост, `static/` и битые адреса пропускает | `html.parser`, `urllib.parse` | +| Дерево | `build_children`, `walk`, `_ref_code`, `_ref_id`, `_orderno` | `build_children` — `{код родителя: [дети]}`: с проектом — один список документов проекта и связи `parent`/`tree_parent` (ссылка разрешается в код через карту id → код), без проекта — обход в ширину по `tree_parent.code`; соседи по `(orderno, name, code)`. `walk` — обход в глубину, корень первым, каждый код один раз (циклы, два родителя) | `EvaClient` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http` | `os`, `uuid` | | Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `claimed` → `started` → `finished` → `acked`; восстановление; `prune` | файловая система | -| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` | +| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (50 страниц или 4 МБ; 413 — ошибка с кодом страницы/границами пачки), отчёт `{root_title, codes, eva_calls}`. `do_wiki_fetch`: `download` → `POST /agent/files`, отчёт `{attachment_id, file, size}`; «чужой хост», «файл больше 10 МБ», «файл не найден в Eva». Ошибки: сбой хаба — `HubError` («хаб: …»), `EvaError` — «Eva: …», 5xx и сеть — повтор через `Runner` | `EvaClient`, `HubClient`, `classify_links`, `build_children`, `walk` | | Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | @@ -42,3 +44,5 @@ ## Открытые вопросы - Живая проверка на виртуалке с настоящей Eva (после merge hub#92). +- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой + живой синхронизации (M-5, design 42). diff --git a/CLAUDE.md b/CLAUDE.md index 79f5f05..d84c610 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,8 @@ ## Первая подсистема MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева, `wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub -jira-hub agent publish src/agent.py` (из основного checkout), как у jira-agent; токен — «агент wiki». +jira-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira); +порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki». ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/README.md b/README.md index bae4b19..027640d 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,75 @@ Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, один файл `src/agent.py` и запускатель `src/run.py`. +Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается. + ## Установка на виртуалку -1. Каталог рядом с jira-agent, например `C:\...\eva-agent\` или `~/eva-agent/`. +1. Локальный каталог рядом с jira-agent, например `~/eva-agent/` (запускать из него, не из общей папки). 2. Положить в него `agent.py` и `run.py` (из `src/`) и `agent.json` (из `agent.example.json`): - `hub_url` — тот же `folder:<путь>`, что у jira-agent (общая папка с хабом; при VPN виртуалка не видит Mac mini по сети), либо `http://<хост>:8790`; - `hub_token` — строка «агент wiki» из `jira-hub tokens` (токен именно агента wiki, не jira и не клиентский); - - `eva.base_url` — адрес Eva; `eva.token` — токен из профиля Eva (Безопасность); + - `eva.base_url` — адрес Eva **только origin**: схема, хост и порт, без пути и без `/` в конце, например + `https://eva.example.ru`. Агент сам добавляет к нему `/api/` (JSON-RPC), `/files/…` (вложения) и + `/project/Document/<код>` (ссылка на страницу); «свой» адрес вложения сравнивается с ним же, поэтому путь в + `base_url` (`https://eva.example.ru/eva`) ломает и вызовы, и проверку хоста; + - `eva.token` — токен из профиля Eva (Безопасность); - `verify_ssl`, `ca_bundle` — при корпоративном сертификате. `agent.json` содержит токены: не коммитить, не выносить с виртуалки. 3. Проверка: `python3 agent.py check` — печатает «хаб: доступен…» и «eva: ok»; код 0, при недоступности хаба или Eva — 3. 4. Запуск: `python3 run.py` (запускатель следит за версиями и самообновлением). `python3 agent.py run` — без запускателя. -5. Автозапуск — как у jira-agent (планировщик задач / launchd по тому же образцу); запускатель сам перезапускает агента. +5. Автозапуск — launchd, как у jira-agent, со своей меткой (см. ниже); запускатель сам перезапускает агента. Другие команды: `python3 agent.py run --once` (выполнить выданное и выйти при первом пустом claim), `selftest` (версия + `check`, код 0 или 3), `prune` (чистка журнала). +Проверить при первой живой синхронизации: как Eva отвечает на протухший токен — перенаправлением на SSO (агент +не идёт на чужой адрес и пишет «перенаправление … отклонено»), страницей входа с кодом 200 («ответ не JSON») или 401. + +## Автозапуск через launchd + +Пример `~/Library/LaunchAgents/ru.aepif.eva-agent.plist` (метка своя, не `ru.aepif.jira-agent`: агенты запускаются +и останавливаются независимо): + +```xml + + + + + Labelru.aepif.eva-agent + ProgramArguments + + /usr/bin/python3 + -u + /Users/USER/eva-agent/run.py + + WorkingDirectory/Users/USER/eva-agent + RunAtLoad + KeepAlive + StandardOutPath/Users/USER/eva-agent/agent.log + StandardErrorPath/Users/USER/eva-agent/agent.log + + +``` + +Подставить свой путь вместо `USER`. Запуск и остановка: + +``` +launchctl load ~/Library/LaunchAgents/ru.aepif.eva-agent.plist +launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist +``` + +`WorkingDirectory` нужен, чтобы `agent.json` и `journal.jsonl` лежали рядом с `agent.py`. + ## Выкатка новых версий Из основного checkout репозитория (не из worktree), при поднятой версии `AGENT_VERSION` в `src/agent.py`: uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py -Агент подтянет её сам (`auto_update`); `min_agent_version` хаба поднимать после того, как `agent_status` покажет новую. +Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента +jira) поднимать после того, как `agent_status` покажет новую версию агента wiki. ## Разработка diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index bcfda91..b60909d 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -212,3 +212,61 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - Хаб принял всё, что требует его спека (расхождений с хабом не найдено). Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных. + +36. **`classify_links` не падает на нестандартном HTML и битых адресах (I-1).** + - На Python 3.9 `_markupbase` зовёт `error()` на ``, `` и подобном, а базовый `error()` бросает + `NotImplementedError`. `_LinkParser.error()` бросает своё `_ParseError`, а `parse_marked_section` ловит его и + пропускает конструкцию до `>` (как «мусорный комментарий»; нет `>` — ждёт данных, при `close()` её пропускает + `goahead`). Известные секции (``, ``, ``) разбираются как раньше. + - Простой no-op `error()` не годится: после него `parse_marked_section` падает на несвязанной переменной `match`, а + `_scan_name` — на распаковке `None`. + - Запасной путь: `feed`/`close` обёрнуты в `try` (`_ParseError`, `NotImplementedError`, `AssertionError`), собранное до + сбоя сохраняется, в лог — строка. + - Ссылка, на которой `urljoin`/`urlsplit` бросают `ValueError` (`http://[::1`), пропускается, остальные остаются. + + Обоснование: одна битая конструкция в HTML страницы роняла весь `wiki_sync` «внутренней ошибкой агента». + Цена ошибки: ссылка внутри нераспознанной секции не попадёт в `attachments`/`links` (останется в HTML как есть). +37. **`wiki_fetch`: адрес кодируется перед запросом, отказ по хосту — `ForeignHost` (I-2).** + - Путь и запрос после `base_url` кодируются `quote(…, safe="/%:@!$&'()*+,;=-._~")` (у запроса ещё `?`); готовые + `%XX` не кодируются повторно, одиночный `%` без двух шестнадцатеричных цифр становится `%25`; фрагмент `#…` + отбрасывается. + - `download` бросает `ForeignHost("чужой хост")` (другие схема, хост, порт, userinfo, адрес не под `base_url`) или + `ForeignHost("некорректный адрес")` (`urlsplit` не разбирает: `http://[::1`, порт не числом). `do_wiki_fetch` + ловит только `ForeignHost`; прочие `ValueError` (в том числе `UnicodeEncodeError`) уходят в «внутреннюю ошибку + агента», а не маскируются под «чужой хост». + - `url` вложения в `classify_links` не перекодируется: это ключ склейки вложений в хабе, менять его форму — вне MVP. + + Обоснование: имена файлов в Eva с пробелами и кириллицей; без кодирования `http.client` отказывал (`InvalidURL` + повторялся как обрыв связи, `UnicodeEncodeError` выдавался за «чужой хост»). + Цена ошибки: если Eva ждёт путь в другой кодировке (не UTF-8), файл не найдётся (404 «файл не найден в Eva»). +38. **`EvaError` в команде — `failed` «Eva: <текст>» без повтора (M-1).** Текст уже очищен от токена (решение 23). + Обоснование: ошибка JSON-RPC (нет прав, неверный фильтр) — ответ Eva, а не сбой агента; повтор не поможет. + Цена ошибки: временная ошибка Eva, пришедшая в `error`, не повторится автоматически — повторяет пользователь. +39. **413 хаба на пачку страниц — ошибка с кодом страницы или границами пачки (M-3).** «хаб: HTTP 413: страница DOC-x + больше 16 МБ — хаб не принял: <ответ хаба>» для пачки из одной страницы; «пачка страниц DOC-a…DOC-b (N шт.) больше + 16 МБ …» иначе. Не повторяется. Пачка из нескольких страниц больше 16 МБ невозможна (пачка закрывается на 4 МБ), но + текст на этот случай есть. + Обоснование: по «HTTP 413» без кода не понять, какую страницу Eva чистить. Цена ошибки: нет. +40. **`list_docs` останавливается, если ответ страницы не приносит новых записей (M-4).** Записи склеиваются по + JSON-представлению (`sort_keys`); полная страница без новых записей — останов с записью в лог, собранное + возвращается. Так ловятся оба случая «Eva игнорирует `slice`»: весь список каждый раз и одна и та же первая страница. + Обоснование: иначе бесконечный цикл вызовов Eva. Цена ошибки: если Eva игнорирует `slice` и отдаёт только первые + N записей, дерево будет неполным без явного отказа (видно в логе). +41. **`Executor(eva, hub)`: удалены неиспользуемые `Executor.s` (настройки) и `Executor.sleep` (M-7).** Настройки, + нужные исполнителю (`wiki_pacing`), ставит `Runner.load_settings` прямо в `EvaClient.pacing`. + Цена ошибки: нет. +42. **Перенаправление Eva на SSO (M-5) не чинится до живой проверки.** Сейчас перенаправление на другой адрес + отклоняется (`RedirectAway` → «перенаправление … отклонено»), страница входа с кодом 200 даёт «ответ не JSON». + Как именно Eva банка отвечает на протухший токен, неизвестно; чинить вслепую — угадывать формат. В README и + ARCHITECTURE строка «проверить при первой живой синхронизации». + Цена ошибки: при протухшем токене текст ошибки менее понятен («ответ не JSON» вместо «токен Eva недействителен»). +43. **Документация (I-3, T6.1, T6.4, M-2, M-6).** + - Выкатка — `jira-hub agent publish --module wiki src/agent.py` (без `--module` хаб публикует версию агенту `jira`, + `registry.DEFAULT_AGENT`); порог — `wiki_min_agent_version` (ключ модуля wiki хаба, `agent_min_version_key`). + - `eva.base_url` — только origin: к нему добавляются `/api/`, `/files/…`, `/project/Document/<код>`, и с ним же + сравнивается адрес вложения. `agent.example.json` уже содержит origin без пути (`https://eva.example.ru`); JSON + без комментариев, поэтому пояснение — в README. + - Агент — только macOS (`fcntl` в `acquire_lock`): из README убраны `C:\…` и «планировщик задач», добавлен пример + launchd с меткой `ru.aepif.eva-agent` по образцу jira-agent. + + Цена ошибки: нет. -- 2.47.2 From 9320de3adfb3cac00561476c543bf3f0850dfb6e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 06:06:29 +0400 Subject: [PATCH 15/15] =?UTF-8?q?=D0=A0=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=2044=E2=80=9345:=20=D0=BE=D0=B1=D1=8A=D1=91=D0=BC=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B,=20=D0=BE=D1=82=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20T2.3=20=D0=B8=20T5.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index b60909d..efa1fbb 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -270,3 +270,10 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге launchd с меткой `ru.aepif.eva-agent` по образцу jira-agent. Цена ошибки: нет. +44. **Финальная волна правок — один fixer, объём: I-1, I-2, I-3, M-1–M-4, M-6, M-7, T6.1, T6.4, T5.2; M-5 не чинится (R-14).** + Обоснование: M-5 (отказ на `text/html`) мог бы ложно отвергнуть настоящие `.html`-вложения; поведение Eva при + просроченном токене проверяется на первой живой синхронизации. + Цена ошибки: при SSO-редиректе страница входа сохранится как вложение; правится одной проверкой Content-Type. +45. **T2.3 и T5.1 отклонены по итогам финального ревью (R-15).** `Http.__init__` делает `base.rstrip("/")`; размер + из ответа хаба равен `len(decoded)`, и хаб сверяет его с `files.size`. + Цена ошибки: нет (оба подтверждены ревьюером по коду). -- 2.47.2